Eleventy-Build schneller machen: erst messen, dann optimieren
Ein Eleventy-Build mit rund 2150 Dateien, von 4:00 auf gut 1:30 Minuten — mit drei kleinen Änderungen und einem gestrichenen HTML-Minify, ohne inhaltliche Änderung der Ausgabe. Wie ich gemessen habe, welche Bremsen im Profil auftauchten und warum inkrementelles Bauen nicht die Antwort war.
von Jean Pierre Kolb ·
Diese Knowledge Base ist inzwischen groß geworden: gut 1100 HTML-Seiten in zwei Sprachen, dazu für jede Inhaltsseite ein Markdown-Zwilling, zusammen rund 2150 Dateien. Ein vollständiger Build dauerte zuletzt vier Minuten. Und weil sich bei einem neuen Blog-Artikel nur eine Handvoll Dateien ändert, lag die Frage nahe: Kann ich nicht einfach nur das bauen, was sich geändert hat?
Ich habe die Frage nicht beantwortet, sondern zuerst gemessen. Das Ergebnis war eindeutiger, als ich erwartet hatte: Ein inkrementeller Build hätte kaum etwas gebracht. Drei kleine Änderungen an der Konfiguration dagegen haben den Build von 239 auf 150 Sekunden gedrückt, bei identischem Inhalt der Ausgabe, Seite für Seite verglichen. Und als ich danach die Kosten des HTML-Minify neben seinen Nutzen gelegt habe, habe ich es gestrichen: Seitdem dauert der Build 92 Sekunden.
In diesem Artikel zeige ich dir, wie ich gemessen habe, welche drei Bremsen im Profil auftauchten, was nicht geholfen hat, warum das Minify gehen musste und wie du prüfst, dass eine Optimierung nichts verändert. Zwei der drei Bremsen dürften in vielen Eleventy-Projekten stecken.
Die Ausgangsfrage: inkrementell bauen?
Der Build dieser Seite besteht aus vier Schritten: Eleventy rendert die Seiten, esbuild bündelt das JavaScript, Tailwind erzeugt das CSS, Pagefind baut den Suchindex. Gemessen auf meinem Entwicklungsrechner:
| Schritt | Zeit |
|---|---|
| Eleventy | 229,7 s |
| esbuild | 0,02 s |
| Tailwind CSS | 0,5 s |
| Pagefind (998 Seiten) | 4,8 s |
| Gesamt | 239 s |
Feeds, Sitemap und llms.txt sind einzelne Templates und kosten praktisch nichts. 96 Prozent der Zeit stecken in Eleventy — also genau in dem Teil, den ein inkrementeller Ansatz retten müsste.
Und dort hilft er hier nicht. Eleventy kennt zwar --incremental, aber sinnvoll ist es nur zusammen mit --watch oder --serve: Ein einmaliger Build weiß nicht von selbst, was sich seit dem letzten Lauf geändert hat — es sei denn, du nennst ihm die Datei (--incremental=datei.md). Wichtiger noch: Fast jede Seite dieser Knowledge Base liest collections.all — für die hreflang-Verweise, die Sidebar, die Breadcrumbs, den Vor-und-zurück-Pager. Für Eleventys Abhängigkeitsgraph heißt das: Ändert sich ein Artikel, sind alle Seiten betroffen. Pagefind wiederum indiziert ohnehin immer komplett, braucht dafür aber nur fünf Sekunden.
Damit war die eigentliche Frage eine andere: Warum braucht Eleventy für 2150 Dateien fast vier Minuten?
So habe ich gemessen
Eleventy bringt eine eingebaute Zeitmessung mit, die über die DEBUG-Variable eingeschaltet wird. Dazu habe ich Node ein CPU-Profil schreiben lassen. Die Ausgabe landet in einem Scratch-Verzeichnis, damit das echte _site/ unberührt bleibt:
ELEVENTY_ENV=production NODE_ENV=production DEBUG='Eleventy:Benchmark*' \
node --cpu-prof --cpu-prof-dir=/tmp/prof \
./node_modules/@11ty/eleventy/cmd.cjs --output=/tmp/site > /tmp/bench.log 2>&1Die Benchmark-Zeilen listen die Zeit je Filter, Shortcode und Transform. Das ist ein guter Einstieg — mit einer Falle, in die ich fast getappt wäre: Asynchrone Transforms erscheinen dort viel zu billig. Mein HTML-Minify stand mit 8 Sekunden in der Liste. Ein Build ohne Minify war aber 100 Sekunden schneller. Der Benchmark misst offenbar nur den synchronen Teil.
Das CPU-Profil hat diese Lücke nicht. Die .cpuprofile-Datei ist JSON; mit einem kurzen Node-Skript lässt sich die Rechenzeit nach node_modules-Paket aufsummieren und für einzelne heiße Funktionen die Aufrufkette zurückverfolgen. So kam das Bild zustande, auf dem alles Weitere aufbaut:
| Wo die Zeit hängt | Anteil im Profil |
|---|---|
HTML-Minify (html-minifier-terser mit terser und clean-css) |
~55–60 s |
| linkedom: Seiten parsen und wieder serialisieren | ~33 s |
EleventyHtmlBasePlugin (posthtml, Pfad-Präfix /db/) |
~19–25 s |
| Shiki: Grammatiken laden und Code hervorheben | ~17 s |
slugify für die Anker der Überschriften |
~10 s |
| Garbage Collector | ~49 s |
Was dort nicht auftaucht, ist ebenso aufschlussreich: Meine Navigations-Filter, die auf jeder Seite collections.all durchsuchen, kosten zusammen rund fünf Sekunden. Genau die hätte ich ohne Messung als Erstes verdächtigt.
Bremse 1: ein Plugin, das jede Seite neu parst
Für das Vorab-Rendern von Seiten beim Überfahren eines Links nutze ich die Speculation Rules API (öffnet in neuem Tab). Eingebunden war sie über eleventy-plugin-speculation-rules. Ein Blick in dessen Quelltext zeigt, was es pro Seite tut:
eleventyConfig.addTransform("speculation-rules", function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html")) {
const { document } = parseHTML(content);
const head = document.querySelector("head");
// ... append a <script type="speculationrules"> with a constant JSON body
return document.toString();
}
return content;
});Jede HTML-Seite wird mit linkedom vollständig in einen DOM-Baum zerlegt und wieder zu Text zusammengesetzt — nur um ein <script> anzuhängen, dessen Inhalt auf jeder Seite identisch ist. Das JSON wird einmal beim Start berechnet und ändert sich nie.
Ein konstantes Element gehört ins Layout. Ich habe das fertige Script aus einer gebauten Seite kopiert und als Partial abgelegt, das beide Layouts als letztes Element im <head> einbinden:
<script type="speculationrules">{"prerender":[{"source":"document","where":{"and":[{"href_matches":["/*"]},{"not":{"href_matches":"/*.zip"}},{"not":{"href_matches":"/*.pdf"}},{"not":{"selector_matches":"a[rel=nofollow]"}},{"not":{"href_matches":["/db/demo/*","/db/en/demo/*"]}},{"not":{"selector_matches":".no-prerender"}}]},"eagerness":"moderate"}]}</script>Das Plugin flog aus den Abhängigkeiten. Der Eleventy-Lauf sank von 230 auf 170 Sekunden — knapp eine Minute für eine einzige Zeile im Layout. Der Benchmark hatte für den Transform nur 19 bis 29 Sekunden ausgewiesen; den Rest vermute ich im Garbage Collector, der die großen DOM-Bäume wieder abräumen muss.
Eine Nebenwirkung lohnt den Hinweis: Weil das Plugin nach dem Minify lief, war seine linkedom-Serialisierung das, was tatsächlich ausgeliefert wurde. Ohne das Plugin steht jetzt wieder die Fassung des Minifiers in den Dateien: <!doctype html> statt <!DOCTYPE html> und <path …/> statt <path … />. Für den Browser ist das dasselbe; ein Byte-Vergleich meldet es trotzdem.
Das Plugin liegt aktuell in Version 2.0.0 vor und arbeitet genau so. Wenn du es nutzt, kannst du denselben Tausch machen.
Bremse 2: der eingebaute slugify-Filter
Die zweite Bremse fand sich über die Aufrufkette einer unscheinbaren Funktion: escapeStringRegexp, gerufen aus transliterate, gerufen aus slugify, gerufen aus Eleventys eigenem Filter. Den hatte ich an markdown-it-anchor übergeben, damit die Überschriften IDs bekommen — ein Muster, das du in vielen Eleventy-Konfigurationen findest:
mdLib.use(markdownItAnchor, {
slugify: eleventyConfig.getFilter("slugify"),
});Eleventy 3.1.7, die aktuelle stabile Version, bringt dafür @sindresorhus/slugify in Version 2 mit, und die hängt an @sindresorhus/transliterate 1.x. Deren Kern sieht so aus:
const doCustomReplacements = (string, replacements) => {
for (const [key, value] of replacements) {
string = string.replace(new RegExp(escapeStringRegexp(key), "g"), value);
}
return string;
};Die Ersetzungstabelle hat knapp 1900 Einträge — Umlaute und Akzente, kyrillische, griechische, arabische, armenische und georgische Zeichen bis hin zu Emoji. Für jede Überschrift baut die Funktion also knapp 1900 reguläre Ausdrücke neu. Eleventy merkt sich zwar bereits berechnete Slugs, aber in dieser Knowledge Base gibt es 7080 verschiedene Überschriften. Jede kostet etwa 1,7 Millisekunden.
Die aktuelle transliterate 2.3.1 kompiliert ihre Ersetzungen dagegen einmal beim Laden zu einem einzigen Muster. Im Projekt war @sindresorhus/slugify 3.0.1, das diese Version nutzt, ohnehin schon installiert, also habe ich das Anker-Plugin direkt damit gefüttert, mit denselben Optionen, die Eleventys Filter setzt:
import slugify from "@sindresorhus/slugify";
export function headingSlug(title) {
return slugify(String(title), { decamelize: false });
}Bei Anker-IDs ist Vorsicht Pflicht: Sie sind Link-Ziele, eine geänderte ID bricht jeden Deep-Link von außen. Deshalb habe ich vor dem Tausch alle 7080 Überschrifts-Texte aus den Quellen gezogen und durch beide Funktionen geschickt. Ergebnis: 0 Abweichungen, Rechenzeit 11,8 Sekunden gegenüber 0,09 Sekunden. Ein Unit-Test hält die heiklen Fälle fest — Umlaute, &, Gedankenstriche, typografische Anführungszeichen, CamelCase, Emoji.
Der Eleventy-Lauf sank von 170 auf 145 Sekunden. Gut zu wissen: Die neueren Alpha-Versionen von Eleventy 4 (ab 4.0.0-alpha.5) — dem künftigen Build Awesome — bringen bereits @sindresorhus/slugify 3 mit. Mit dem Update erledigt sich diese Bremse also von selbst.
Bremse 3: Shiki mit allen Sprachen
Für die Syntaxhervorhebung nutze ich Shiki (öffnet in neuem Tab). Die Initialisierung stand so in der Konfiguration, wie sie in vielen Anleitungen steht:
const highlighter = await createHighlighter({
themes: [theme],
langs: Object.keys(bundledLanguages),
});bundledLanguages umfasst 346 Einträge: rund 240 Sprachen, dazu ihre Aliasse wie js für JavaScript. Sie alle zu laden, dauerte bei jedem Build rund 10 Sekunden. Die Inhalte dieser Seite nutzen 21 Sprachen unter 25 Namen — mit großem Abstand vorn Bash, dahinter CSS, YAML, Markdown und HTML.
Eine feste Liste wäre die einfachste Lösung, aber auch eine Falle: Schreibe ich morgen einen Artikel mit einem rust-Block, fehlt die Hervorhebung, und niemand merkt es. Deshalb sucht der Build jetzt vor dem Start alle Markdown-Quellen nach Code-Blöcken ab und lädt genau die Sprachen, die dort genannt sind:
eleventyConfig.on("eleventy.before", async ({ directories }) => {
const sources = fs
.globSync("**/*.md", { cwd: directories.input })
.map((file) => fs.readFileSync(path.join(directories.input, file), "utf8"));
highlighter = await createHighlighter({
themes: [theme],
langs: languagesToLoad(sources, Object.keys(bundledLanguages)),
});
});Die Suche darf im Zweifel zu viel finden, nie zu wenig: Sie erkennt Code-Blöcke auch eingerückt, in Listen und in Zitaten, und liest die Sprache mit derselben Funktion, die auch der Renderer nutzt. Ein überzähliger Treffer lädt nur eine Grammatik mehr. Rutscht trotzdem eine Sprache durch, gibt der Renderer eine Warnung aus, statt die Farbe stillschweigend wegzulassen. Im Watch-Modus läuft die Suche vor jedem Rebuild, eine neue Sprache ist also sofort da.
Eleventy brauchte danach 136 statt 145 Sekunden, die Ausgabe blieb identisch.
Was nicht geholfen hat
Im Profil standen zwei weitere Transforms, die Seiten mit linkedom parsen: einer verpackt Tabellen für die responsive Darstellung, einer markiert externe Links. Zwei Durchläufe zu einem zusammenzulegen, sah nach weiteren zehn bis zwanzig Sekunden aus.
Gemessen war es Rauschen: 138 statt 136 Sekunden. Der Grund stand nicht im Profil, sondern in einer einfachen Zählung. Fast jede Seite hat einen externen Link, schon wegen der Profil-Links im Footer, und braucht den Durchlauf ohnehin. Eine Tabelle haben nur 142 Seiten. Nur diese wurden bisher doppelt geparst, und das kostete insgesamt wenige Sekunden.
Ich habe die Änderung verworfen. Die Lehre daraus nehme ich mit: Ein Profil zeigt, was etwas kostet — nicht, was du sparst, wenn du es änderst. Bevor du einen Umbau anfängst, zähl nach, wie viele Seiten er überhaupt betrifft.
Die teuerste Zeile: das HTML-Minify
Der größte einzelne Posten ist das HTML-Minify. Vor den Änderungen brauchte ein Build ohne Minify 130 statt 230 Sekunden. Nach den drei Änderungen war der Abstand kleiner, aber immer noch knapp eine Minute pro Build. Dem steht ein kleiner Nutzen gegenüber, sobald der Server komprimiert (gzip in der Standardstufe, Brotli in Stufe 5):
| Alle HTML-Seiten | minifiziert | nicht minifiziert |
|---|---|---|
| unkomprimiert | 77,0 MB | 86,8 MB |
| gzip | 16,1 MB | 16,8 MB |
| Brotli | 13,8 MB | 14,4 MB |
Nach gzip spart das Minify 4,5 Prozent, rund 0,7 KB pro Seite; nach Brotli sind es 4,1 Prozent. Knapp eine Minute bei jedem Build gegen 0,7 KB pro Seitenaufruf — als ich beide Zahlen nebeneinander hatte, habe ich das HTML-Minify gestrichen.
Wichtig ist, was dabei nicht wegfällt. Das Stylesheet und das JavaScript-Bundle werden in eigenen Build-Schritten minifiziert, von Tailwind (--minify) und esbuild (--minify); daran ändert sich nichts. Auch Sitemap, Feeds und JSON-Dateien bleiben komprimiert, das kostet bei einer Handvoll Dateien nichts. Unkomprimiert ist nur das HTML selbst, samt dem kleinen Inline-Script im Kopf jeder Seite und dem JSON-LD.
Bevor ich die Zeile gelöscht habe, wollte ich wissen, dass sich wirklich nur Leerraum ändert. Der Beweis war einfach: Ich habe jede neue, unminifizierte Seite mit den alten Minify-Einstellungen nachträglich minifiziert und mit der bisher ausgelieferten Datei verglichen. Alle 1143 HTML-Dateien waren Byte für Byte gleich. Für den Browser ist der einzige Unterschied Leerraum und ein paar HTML-Kommentare im Quelltext.
Eleventy brauchte danach 83 statt 141 Sekunden.
So prüfst du, dass nichts kaputtgeht
Eine Build-Optimierung soll die Ausgabe nicht verändern. Das lässt sich prüfen, statt es anzunehmen — mit einem Byte-Vergleich jeder einzelnen Datei:
- Vor der Änderung den aktuellen Build als Referenz sichern.
- Nach der Änderung, aber vor dem Commit, in ein Scratch-Verzeichnis bauen und jede Datei gegen die Referenz vergleichen.
- Jede Abweichung erklären können, bevor du committest.
Ein Detail macht den Vergleich hier besonders einfach: Build-Hash und Zeitstempel in meinen Seiten stammen aus dem letzten Git-Commit, nicht aus der Uhrzeit. Solange die Änderung nicht committet ist, muss die Ausgabe Byte für Byte gleich sein. Nach dem Commit tausche ich vor dem Vergleich die Werte des alten Commits gegen die des neuen. Dabei gilt: Ein git commit --amend erzeugt einen neuen Commit mit neuem Zeitstempel — als Referenz zählt der Hash nach dem Amend.
Ein Befund dieses Vergleichs hatte mit meinen Änderungen nichts zu tun: In sitemap.xml und llms.txt wechselten Einträge mit gleichem Datum von Build zu Build die Plätze. Die Ursache ist collectionsApi.getAll(). Es liefert die Seiten in der Reihenfolge, in der Eleventy sie asynchron fertig verarbeitet hat; erst getAllSorted() sortiert nach Datum und Pfad. Für die Sitemap ist die Reihenfolge bedeutungslos, deshalb habe ich es gelassen. Für den Vergleich heißt das: diese beiden Dateien als sortierte Zeilen vergleichen.
Das Ergebnis
| Stand | Eleventy | Build gesamt |
|---|---|---|
| vorher | 230 s | 239 s |
| Speculation Rules im Layout | 170 s | |
Anker-Slugs mit aktuellem slugify |
145 s | |
| Shiki nur mit genutzten Sprachen | 136 s | |
| nach den drei Bremsen, frischer Build | 141 s | 150 s |
| ohne HTML-Minify, frischer Build | 83 s | 92 s |
Die Zeiten sind Eleventys eigene Angabe aus jeweils einem Lauf, und einzelne Läufe schwanken spürbar: Derselbe Stand vor den Änderungen brauchte einmal 214, einmal 230 Sekunden. Die Zwischenstände sind deshalb Richtwerte, daher auch der Unterschied zwischen dem letzten Zwischenstand und dem frischen Build am Schluss. Belastbar sind die Vergleiche der vollständigen Builds und die Einzelmessungen der Ursachen, etwa 11,8 gegen 0,09 Sekunden beim Slug. Unterm Strich braucht der Build gut 60 Prozent weniger Zeit: Die drei Bremsen brachten gut ein Drittel, das Minify den Rest. Inhaltlich ist die Ausgabe identisch; abweichend sind nur Leerraum und Kommentare im HTML, die beschriebene Schreibweise nach dem Wegfall des Plugins sowie Build-Hash und Datum der neuen Commits.
Was ich für künftige Projekte mitnehme
- Erst messen. Meine erste Vermutung — die Navigations-Filter — war falsch. Die tatsächlichen Bremsen hätte ich ohne Profil nicht gefunden.
- Kein Plugin, das jede Seite für eine Kleinigkeit neu parst. Was auf jeder Seite gleich ist, gehört ins Layout.
- Höchstens ein DOM-Durchlauf pro Seite, und nur, wenn eine billige Textprüfung ihn nötig macht.
- Shiki nur mit den genutzten Sprachen starten — automatisch ermittelt, nicht von Hand gepflegt.
- Anker-Slugs nicht über Eleventys eingebauten Filter, solange Eleventy 3 die alte
transliteratemitbringt. - HTML-Minify nachrechnen. Es war der teuerste Einzelschritt, und sein Nutzen nach Kompression war klein. Minifiziertes CSS und JS kommt ohnehin aus den eigenen Build-Schritten.
FAQ
Lohnt sich --incremental für einen Produktions-Build?
Nur, wenn deine Seiten nicht alle voneinander abhängen. Sobald jede Seite collections.all liest, etwa für Navigation oder Sprachverweise, rendert Eleventy bei einer Änderung fast alles neu. Außerdem erkennt der Modus Änderungen nur zusammen mit --watch oder --serve von selbst.
Ändern sich durch den slugify-Tausch meine Anker-IDs?
In dieser Knowledge Base bei keiner der 7080 Überschriften. Für deine eigenen Inhalte prüfst du das am sichersten genauso: alle Überschriften durch beide Funktionen schicken und die Ergebnisse vergleichen.
Muss ich das HTML-Minify abschalten? Nein, es ist eine Abwägung zwischen Build-Zeit und Dateigröße. Miss beides für deine Seite: die Build-Zeit mit und ohne Minify und die Größe nach gzip oder Brotli. Bei mir stand knapp eine Minute pro Build gegen 0,7 KB pro Seite, und das war eindeutig.
Werden CSS und JavaScript dann auch nicht mehr minifiziert? Nicht, wenn sie wie hier aus eigenen Build-Schritten kommen: Tailwind und esbuild minifizieren ihre Ausgabe selbst. Unminifiziert bleiben nur Scripts und Styles, die direkt im HTML stehen.
Fazit
Die Frage nach dem inkrementellen Build war die falsche Frage. Nicht das Neubauen war das Problem, sondern drei Stellen, die bei jedem Build unnötig Arbeit erzeugten: ein Plugin, das jede Seite für ein konstantes Script zerlegt, ein Slug-Filter, der pro Überschrift knapp 1900 reguläre Ausdrücke baut, und ein Highlighter, der rund 240 Sprachen lädt, um 21 zu nutzen. Keine davon war im Code offensichtlich, alle drei standen deutlich im Profil. Dazu kam ein Minify, dessen Nutzen nach der Kompression kaum noch messbar war. Aus vier Minuten wurden anderthalb.
Wenn dein Eleventy-Build dir zu lange dauert: Fang mit DEBUG=Eleventy:Benchmark* und node --cpu-prof an, bevor du über Caches, inkrementelle Builds oder einen anderen Generator nachdenkst.
Weiterlesen
- Warum 11ty: sechs Jahre statisch — warum diese Seite überhaupt mit Eleventy gebaut wird.
- Pagefind in der Praxis — der Suchindex, der in diesem Build fünf Sekunden kostet.
- Sicheres Arbeiten mit Node.js und npm — warum ich jede Abhängigkeit weniger als Gewinn sehe.
- Eleventy: Analyzing Build Performance (öffnet in neuem Tab) — die offizielle Dokumentation zur Benchmark-Ausgabe.
- Node.js:
--cpu-prof(öffnet in neuem Tab) — die Option für das CPU-Profil. - Shiki: Bundles und Sprachen (öffnet in neuem Tab) — welche Sprachen Shiki mitbringt und wie du nur die nötigen lädst.
- eleventy-plugin-speculation-rules auf GitHub (öffnet in neuem Tab) — das Plugin, das ich durch eine Zeile im Layout ersetzt habe.