Speeding Up an Eleventy Build: Measure First, Then Optimise

An Eleventy build of about 2,150 files, cut from 4:00 to just over 1:30 minutes — with three small changes and a dropped HTML minify, without changing the content of the output. How I measured, which brakes showed up in the profile, and why incremental builds were not the answer.

by ·

This knowledge base has grown large: just over 1,100 HTML pages in two languages, plus a Markdown twin for every content page, about 2,150 files in total. A full build had reached four minutes. And since a new blog article only changes a handful of files, the question was obvious: can't I simply build only what changed?

I didn't answer the question — I measured first. The result was clearer than I expected: an incremental build would have bought almost nothing. Three small configuration changes, on the other hand, brought the build down from 239 to 150 seconds, with identical content in the output, compared page by page. And when I then put the cost of the HTML minify next to its benefit, I dropped it: since then the build takes 92 seconds.

In this article I'll show you how I measured, which three brakes showed up in the profile, what did not help, why the minify had to go, and how you check that an optimisation changes nothing. Two of the three brakes are probably sitting in plenty of Eleventy projects.

The starting question: build incrementally?

This site's build has four steps: Eleventy renders the pages, esbuild bundles the JavaScript, Tailwind generates the CSS, Pagefind builds the search index. Measured on my development machine:

Step Time
Eleventy 229.7 s
esbuild 0.02 s
Tailwind CSS 0.5 s
Pagefind (998 pages) 4.8 s
Total 239 s

Feeds, sitemap and llms.txt are single templates and cost practically nothing. 96 per cent of the time is spent in Eleventy — exactly the part an incremental approach would have to rescue.

And there it doesn't help here. Eleventy does have --incremental, but it's only really useful together with --watch or --serve: a one-off build doesn't know on its own what changed since the last run — unless you name the file (--incremental=file.md). More importantly, almost every page of this knowledge base reads collections.all — for the hreflang links, the sidebar, the breadcrumbs, the previous/next pager. To Eleventy's dependency graph that means: change one article and every page is affected. Pagefind, for its part, always indexes everything anyway, but only needs five seconds for it.

So the real question was a different one: why does Eleventy need almost four minutes for 2,150 files?

How I measured

Eleventy ships with built-in timing that you switch on through the DEBUG variable. On top of that I had Node write a CPU profile. The output goes to a scratch directory so the real _site/ stays untouched:

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>&1

The benchmark lines list the time per filter, shortcode and transform. That's a good starting point — with a trap I nearly fell into: asynchronous transforms show up far too cheap. My HTML minify was listed at 8 seconds. A build without minify, however, was 100 seconds faster. The benchmark apparently only measures the synchronous part.

The CPU profile doesn't have that gap. The .cpuprofile file is JSON; a short Node script can sum up the CPU time per node_modules package and trace the call chain back for individual hot functions. That produced the picture everything else builds on:

Where the time goes Share in the profile
HTML minify (html-minifier-terser with terser and clean-css) ~55–60 s
linkedom: parsing pages and serialising them again ~33 s
EleventyHtmlBasePlugin (posthtml, path prefix /db/) ~19–25 s
Shiki: loading grammars and highlighting code ~17 s
slugify for the heading anchors ~10 s
Garbage collector ~49 s

What does not show up is just as telling: my navigation filters, which search collections.all on every page, cost about five seconds combined. Without measuring, they're exactly what I would have suspected first.

Brake 1: a plugin that re-parses every page

To prerender pages when you hover over a link I use the Speculation Rules API (opens in a new tab). It was wired in through eleventy-plugin-speculation-rules. A look at its source shows what it does per page:

js
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;
});

Every HTML page is fully parsed into a DOM tree with linkedom and serialised back to text — just to append a <script> whose content is identical on every page. The JSON is computed once at startup and never changes.

A constant element belongs in the layout. I copied the finished script from a built page and stored it as a partial that both layouts include as the last element of <head>:

<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>

The plugin was removed from the dependencies. The Eleventy run dropped from 230 to 170 seconds — almost a minute for one line in the layout. The benchmark had listed only 19 to 29 seconds for the transform; I suspect the rest in the garbage collector, which has to clear away the large DOM trees again.

One side effect is worth a note: because the plugin ran after the minify, its linkedom serialisation was what actually shipped. Without the plugin, the files now contain the minifier's version again: <!doctype html> instead of <!DOCTYPE html>, and <path …/> instead of <path … />. To the browser that's the same; a byte comparison still reports it.

The plugin is currently at version 2.0.0 and works exactly like this. If you use it, you can make the same swap.

Brake 2: the built-in slugify filter

The second brake turned up via the call chain of an inconspicuous function: escapeStringRegexp, called from transliterate, called from slugify, called from Eleventy's own filter. I had handed that filter to markdown-it-anchor so that headings get IDs — a pattern you'll find in plenty of Eleventy configurations:

mdLib.use(markdownItAnchor, {
  slugify: eleventyConfig.getFilter("slugify"),
});

Eleventy 3.1.7, the current stable release, ships @sindresorhus/slugify version 2 for this, which depends on @sindresorhus/transliterate 1.x. Its core looks like this:

js
const doCustomReplacements = (string, replacements) => {
  for (const [key, value] of replacements) {
    string = string.replace(new RegExp(escapeStringRegexp(key), "g"), value);
  }
  return string;
};

The replacement table has almost 1,900 entries — umlauts and accents, Cyrillic, Greek, Arabic, Armenian and Georgian characters, even emoji. So for every heading the function rebuilds almost 1,900 regular expressions. Eleventy does cache slugs it has already computed, but this knowledge base has 7,080 distinct headings. Each one costs about 1.7 milliseconds.

The current transliterate 2.3.1, by contrast, compiles its replacements into a single pattern once when it loads. @sindresorhus/slugify 3.0.1, which uses that version, was already installed in the project, so I fed the anchor plugin with it directly, using the same options Eleventy's filter sets:

import slugify from "@sindresorhus/slugify";

export function headingSlug(title) {
  return slugify(String(title), { decamelize: false });
}

With anchor IDs, caution is mandatory: they are link targets, and a changed ID breaks every deep link from outside. So before the swap I pulled all 7,080 heading texts from the sources and ran them through both functions. Result: 0 differences, computing time 11.8 seconds versus 0.09 seconds. A unit test pins down the tricky cases — umlauts, &, dashes, typographic quotes, CamelCase, emoji.

The Eleventy run dropped from 170 to 145 seconds. Good to know: the newer alpha releases of Eleventy 4 (from 4.0.0-alpha.5 on) — the future Build Awesome — already ship @sindresorhus/slugify 3. With that update, this brake disappears on its own.

Brake 3: Shiki with every language

For syntax highlighting I use Shiki (opens in a new tab). The initialisation was in the configuration the way many guides show it:

const highlighter = await createHighlighter({
  themes: [theme],
  langs: Object.keys(bundledLanguages),
});

bundledLanguages has 346 entries: about 240 languages plus their aliases, such as js for JavaScript. Loading all of them took around 10 seconds on every build. This site's content uses 21 languages under 25 names — Bash far ahead of everything else, followed by CSS, YAML, Markdown and HTML.

A fixed list would be the simplest fix, but also a trap: if I write an article with a rust block tomorrow, the highlighting is missing and nobody notices. So the build now scans all Markdown sources for code blocks before it starts and loads exactly the languages named there:

js
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)),
  });
});

When in doubt, the scan may find too much, never too little: it recognises code blocks that are indented, inside lists and inside blockquotes, and reads the language with the same function the renderer uses. A surplus hit only loads one more grammar. If a language still slips through, the renderer prints a warning instead of silently dropping the colours. In watch mode the scan runs before every rebuild, so a new language is available immediately.

Eleventy then needed 136 instead of 145 seconds, and the output stayed identical.

What didn't help

The profile listed two more transforms that parse pages with linkedom: one wraps tables for responsive display, the other marks external links. Merging the two passes into one looked like another ten to twenty seconds.

Measured, it was noise: 138 instead of 136 seconds. The reason wasn't in the profile but in a simple count. Almost every page has an external link, if only because of the profile links in the footer, and needs the pass anyway. Only 142 pages have a table. Only those were parsed twice so far, and that cost a few seconds in total.

I discarded the change. The lesson I'm taking with me: a profile shows what something costs — not what you save when you change it. Before you start a rework, count how many pages it actually affects.

The most expensive line: the HTML minify

The biggest single item is the HTML minify. Before the changes, a build without minify took 130 instead of 230 seconds. After the three changes the gap was smaller, but still almost a minute per build. Set against that is a small benefit once the server compresses (gzip at its default level, Brotli at level 5):

All HTML pages minified not minified
uncompressed 77.0 MB 86.8 MB
gzip 16.1 MB 16.8 MB
Brotli 13.8 MB 14.4 MB

After gzip, the minify saves 4.5 per cent, about 0.7 KB per page; after Brotli it's 4.1 per cent. Almost a minute on every build against 0.7 KB per page view — once I had both numbers side by side, I dropped the HTML minify.

What matters is what does not go away. The stylesheet and the JavaScript bundle are minified in their own build steps, by Tailwind (--minify) and esbuild (--minify); nothing changes there. Sitemap, feeds and JSON files stay compressed too, which costs nothing for a handful of files. Only the HTML itself is no longer minified, including the small inline script in the head of every page and the JSON-LD.

Before deleting the line, I wanted to know that only whitespace really changes. The proof was simple: I minified every new, unminified page after the fact with the old minify settings and compared it with the file shipped so far. All 1,143 HTML files were identical byte for byte. To the browser, the only difference is whitespace and a few HTML comments in the source.

Eleventy then needed 83 instead of 141 seconds.

How to check that nothing breaks

A build optimisation shouldn't change the output. You can check that instead of assuming it — with a byte comparison of every single file:

  1. Before the change, save the current build as a reference.
  2. After the change, but before committing, build into a scratch directory and compare every file against the reference.
  3. Be able to explain every difference before you commit.

One detail makes the comparison especially easy here: the build hash and timestamp in my pages come from the last Git commit, not from the clock. As long as the change isn't committed, the output has to be identical byte for byte. After the commit, I swap the old commit's values for the new ones before comparing. Keep in mind that a git commit --amend creates a new commit with a new timestamp — the reference is the hash after the amend.

One finding of this comparison had nothing to do with my changes: in sitemap.xml and llms.txt, entries with the same date swapped places from build to build. The cause is collectionsApi.getAll(). It returns the pages in the order Eleventy finished processing them asynchronously; only getAllSorted() sorts by date and path. For the sitemap, the order is meaningless, so I left it as it is. For the comparison it means: compare these two files as sorted lines.

The result

State Eleventy Total build
before 230 s 239 s
Speculation Rules in the layout 170 s
Anchor slugs with current slugify 145 s
Shiki with used languages only 136 s
after the three brakes, fresh build 141 s 150 s
without HTML minify, fresh build 83 s 92 s

The times are Eleventy's own figure from a single run each, and individual runs vary noticeably: the same state before the changes took 214 seconds once and 230 seconds another time. The intermediate states are therefore indicative, which also explains the difference between the last intermediate state and the fresh build at the end. What holds up are the comparisons of the complete builds and the individual measurements of the causes, such as 11.8 versus 0.09 seconds for the slugs. All in all, the build takes a good 60 per cent less time: the three brakes accounted for a good third, the minify for the rest. In terms of content the output is identical; the only differences are whitespace and comments in the HTML, the serialisation described above after the plugin was removed, plus the build hash and date of the new commits.

What I'm taking into future projects

  • Measure first. My first guess — the navigation filters — was wrong. I wouldn't have found the actual brakes without a profile.
  • No plugin that re-parses every page for a trifle. Whatever is the same on every page belongs in the layout.
  • At most one DOM pass per page, and only when a cheap text check makes it necessary.
  • Start Shiki with the languages you use — determined automatically, not maintained by hand.
  • Don't build anchor slugs with Eleventy's built-in filter as long as Eleventy 3 ships the old transliterate.
  • Do the maths on HTML minify. It was the most expensive single step, and its benefit after compression was small. Minified CSS and JS come from their own build steps anyway.

FAQ

Is --incremental worth it for a production build? Only if your pages don't all depend on each other. As soon as every page reads collections.all, for navigation or language links for instance, Eleventy re-renders almost everything on a change. On top of that, the mode only detects changes on its own together with --watch or --serve.

Does the slugify swap change my anchor IDs? In this knowledge base, not for a single one of the 7,080 headings. For your own content, the safest check is the same: run all headings through both functions and compare the results.

Do I have to switch off the HTML minify? No, it's a trade-off between build time and file size. Measure both for your site: the build time with and without minify, and the size after gzip or Brotli. For me it was almost a minute per build against 0.7 KB per page, and that was clear-cut.

Won't CSS and JavaScript stop being minified too? Not if, as here, they come from their own build steps: Tailwind and esbuild minify their output themselves. Only scripts and styles placed directly in the HTML stay unminified.

Conclusion

The question about the incremental build was the wrong question. Rebuilding wasn't the problem — three places that created unnecessary work on every build were: a plugin that takes every page apart for a constant script, a slug filter that builds almost 1,900 regular expressions per heading, and a highlighter that loads about 240 languages to use 21. None of them was obvious in the code; all three stood out clearly in the profile. On top of that came a minify whose benefit after compression was barely measurable. Four minutes became one and a half.

If your Eleventy build takes too long: start with DEBUG=Eleventy:Benchmark* and node --cpu-prof before you think about caches, incremental builds or a different generator.

Further reading