docs: add Eleventy build performance optimization spec

This commit is contained in:
svemagie
2026-03-19 22:02:02 +01:00
parent f947014904
commit dbde9007f4

View File

@@ -0,0 +1,145 @@
# Eleventy Build Performance Optimization — Design Spec
**Date:** 2026-03-19
**Goal:** Reduce production build time (GitHub Actions deploy)
**Approach:** Measure first, fix known low-hanging fruit in the same pass
---
## Context
The blog runs Eleventy 3 with a monolithic `eleventy.config.js`. Two `eleventy.before` hooks run outside Eleventy's own timer:
1. **OG image generation** — spawns a subprocess loop using Satori + resvg; manifest-based so incremental, but the loop still initializes every build.
2. **Unfurl prefetch** — walks all content files, parses frontmatter, calls `prefetchUrl()` for every interaction URL (likes, bookmarks, replies, reposts). Network responses are disk-cached by `eleventy-fetch`, but the walk + parse + cache-hit overhead runs unconditionally on every build.
One filter is also a known repeated-work issue:
- **`hash` filter** — reads a file from disk and computes MD5 on every call. The same 23 paths are passed on every page render (hundreds of pages), with no caching.
Inspired by [rmendes.net — Optimizing Eleventy Build Performance](https://rmendes.net/articles/2026/03/10/optimizing-eleventy-build-performance/).
---
## Changes
### 1. Build timing instrumentation
**Purpose:** Establish a baseline and validate that fixes in changes 2 and 3 have real impact.
**Implementation:**
- Add `console.time("[og] image generation")` / `console.timeEnd(...)` around the OG `eleventy.before` hook body in `eleventy.config.js`.
- Add `console.time("[unfurl] prefetch")` / `console.timeEnd(...)` around the unfurl `eleventy.before` hook body.
- No logic is touched; labels are prefixed with `[og]` and `[unfurl]` to match existing log conventions.
**How to use:**
```bash
DEBUG=Eleventy:Benchmark* npm run build 2>&1 | grep -E "Benchmark|og|unfurl"
```
This captures both Eleventy's internal timings (collections, transforms, filters, rendering) and the two before-hook timings in a single build run.
---
### 2. `hash` filter memoization
**Location:** `eleventy.config.js``addFilter("hash", ...)` at ~line 744
**Problem:** `readFileSync()` + MD5 on every call. With 500+ pages each calling `| hash` for 23 asset paths, this is ~1000+ redundant disk reads per build.
**Fix:** Wrap with a `Map` cache, cleared on `eleventy.before`:
```js
const _hashCache = new Map();
eleventyConfig.on("eleventy.before", () => { _hashCache.clear(); });
eleventyConfig.addFilter("hash", (filePath) => {
if (_hashCache.has(filePath)) return _hashCache.get(filePath);
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
const result = createHash("md5").update(readFileSync(fullPath)).digest("hex").slice(0, 8);
_hashCache.set(filePath, result);
return result;
} catch {
return Date.now().toString(36);
}
});
```
**Properties:**
- Cache is per-build (cleared on `eleventy.before`), so a CSS change during `npm run dev` is always picked up on the next rebuild.
- Pattern is identical to existing `_dateDisplayCache` and `_isoDateCache`.
---
### 3. Unfurl prefetch manifest skip
**Location:** `eleventy.config.js` — the `async eleventy.before` hook at ~line 1421
**Problem:** The hook walks all content files and calls `prefetchUrl()` for every interaction URL on every build, even when no new interaction posts have been added since the last build.
**Fix:** A manifest file at `.cache/unfurl-manifest.json` tracks the set of previously prefetched URLs. On each build, only URLs absent from the manifest are prefetched. If no new URLs exist, the hook exits immediately after the walk.
**Manifest lifecycle:**
- **First build / cold cache:** no manifest → full prefetch → manifest written with all URLs.
- **Subsequent builds, no new posts:** manifest loaded → `newUrls` is empty → early return, no network calls.
- **New interaction post added:** manifest loaded → new URL detected → only that URL prefetched → manifest updated.
- **Manifest deleted / corrupted:** falls back to full prefetch (same as first build).
**Implementation sketch:**
```js
eleventyConfig.on("eleventy.before", async () => {
const contentDir = resolve(__dirname, "content");
if (!existsSync(contentDir)) return;
// --- existing walk to collect `urls` Set (unchanged) ---
if (urls.size === 0) return;
// Load manifest of previously seen URLs
const manifestPath = resolve(__dirname, ".cache", "unfurl-manifest.json");
let seen = new Set();
try {
seen = new Set(JSON.parse(readFileSync(manifestPath, "utf-8")));
} catch { /* first build */ }
const newUrls = [...urls].filter(u => !seen.has(u));
if (newUrls.length === 0) {
console.log("[unfurl] No new URLs — skipping prefetch");
return;
}
// Prefetch only new URLs using existing batch logic
// (replace `urlArray` with `newUrls` in the batch loop)
// Update manifest
mkdirSync(resolve(__dirname, ".cache"), { recursive: true });
writeFileSync(manifestPath, JSON.stringify([...urls]));
});
```
**Properties:**
- Manifest stored in `.cache/` — same gitignore and passthrough-copy rules as existing unfurl cache.
- `writeFileSync` is used (not async) to guarantee the manifest is written even if the process exits unexpectedly mid-batch. On failure, next build falls back to full prefetch.
- The content walk still runs on every build (cheap: local FS reads of small frontmatter). Only the prefetch calls are skipped.
---
## Files changed
| File | Change |
|------|--------|
| `eleventy.config.js` | Add timing to 2 before-hooks; memoize `hash` filter; add manifest skip to unfurl hook |
No new files. No new dependencies.
---
## Success criteria
1. `console.time` output visible in production build logs.
2. `DEBUG=Eleventy:Benchmark*` output available for baseline comparison.
3. On a build with no new posts: `[unfurl] No new URLs — skipping prefetch` logged.
4. Hash filter: same cache-busting behaviour as before (values change when files change between builds).
5. No change to rendered output.