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

Source: https://www.jpkc.com/db/en/blog/eleventy-build-beschleunigen/

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:

```bash
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](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API). 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>`:

```html
<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:

```js
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:

```js
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](https://shiki.style/). The initialisation was in the configuration the way many guides show it:

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

- [Why 11ty: six years of static](https://www.jpkc.com/db/en/blog/warum-11ty/) — why this site is built with Eleventy in the first place.
- [Pagefind in practice](https://www.jpkc.com/db/en/blog/pagefind/) — the search index that costs five seconds in this build.
- [Working securely with Node.js and npm](https://www.jpkc.com/db/en/blog/nodejs-npm-absichern/) — why I count every dependency removed as a win.
- [Eleventy: Analyzing Build Performance](https://www.11ty.dev/docs/debug-performance/) — the official documentation for the benchmark output.
- [Node.js: `--cpu-prof`](https://nodejs.org/api/cli.html#--cpu-prof) — the option for the CPU profile.
- [Shiki: Bundles and languages](https://shiki.style/guide/bundles) — which languages Shiki ships and how to load only the ones you need.
- [eleventy-plugin-speculation-rules on GitHub](https://github.com/reatlat/eleventy-plugin-speculation-rules) — the plugin I replaced with one line in the layout.

