10,000 pages, 239 milliseconds
How a 10,000-page bongo build went from 6.9 seconds to 2.2, and a rebuild after an edit to 239 ms, with every step measured.
bongo 1.0 is out. It builds a site of 10,000 Markdown pages in 2.19 seconds. After you edit one page, it builds the site again in about a quarter of a second.
A few weeks ago, the same site took 6.86 seconds every time, and 1.2 GB of memory to do it.
None of the speed came from one clever idea. It came from about a dozen plain changes, each one measured on its own, each one checked against the output of the build before it. This post is the list, with the numbers, and with the ideas that did not work.
#The bench
You cannot make something fast without a fixed thing to measure. So everything here starts from a bench.
bench/content.ts writes a site of 100 sections with 100 pages each. Every page has frontmatter, 2 to 5 sections of text, lists, tables, quotes, and code blocks in 4 languages. Every page has a table of contents, a pager, tags, and dates. The same page count writes the same bytes on every run, so two runs months apart build the same site.
bun run bench builds it fresh, builds it after each kind of edit, runs it in bongo dev, and builds the docs site too. Every row in the results is the median of 5 runs, on one idle M1 Max, with Bun 1.4.3.
The bench also hashes public/. If a change makes the build faster and the hash moves, the change is wrong until I can explain the new bytes.
#Where the time went
Here is the first row:
| wall | user | sys | peak RSS |
|---|---|---|---|
| 6.86 s | 4.19 s | 20.24 s | 1183 MB |
$ bun run bench site
bench/site/
median min … max
wall 6.86 s 6.68 s … 6.96 s
user 4.19 s 4.13 s … 4.22 s
sys 20.24 s 17.42 s … 20.99 s
peak rss 1183.4 MB 1169.9 MB … 1187.2 MB
stage time max rss
boot 40 ms 37.8 MB
start 1.89 s 42.5 MB
loading the config and data/ 41 ms 79.2 MB
compiling layouts and partials 3 ms 80.2 MB
reading content/ 960 ms 400.0 MB
rendering pages 1.49 s 1090.5 MB
bundling assets 50 ms 1097.0 MB
writing pages 2.69 s 1214.4 MB
exit 55 ms 1214.5 MB
Look at sys. The build spent over 20 seconds of CPU time in the kernel, spread across threads, and only 4 seconds running JavaScript. Most of the cost was not rendering. It was the file system.
The stages say the same. start is the build emptying public/, before it loads anything. Together with writing pages, that is 4.6 of the 6.9 seconds. Rendering every page took 1.5.
The templates and the Markdown parser are where the interesting code is. They were not where the time went.
#Stop deleting your output
bongo build started each run by emptying public/, then made every file again. On the bench, emptying took about 1.6 seconds. Making 10,000 new files took about 1.3 seconds. Writing over the same 10,000 files took about 0.15 seconds.
So the build now keeps the last output, writes over it, and deletes only the files it did not write. It still checks every path on the way: a file where a folder must go, a folder where a file must go, a path that differs only in case, and symlinks, which it deletes without ever following.
| change | wall | sys |
|---|---|---|
| before | 4.29 s | 16.48 s |
write over public/ |
2.82 s | 6.60 s |
That was the largest single gain in the whole project, and it did not touch a template.
// build.ts
// before
await sink.clear();
// after
// `bongo build` writes over the files of the last build, which is much
// faster than making them again, then sweeps the rest
await (writes ? sink.reuse() : sink.clear());
// ...
if (writes) await sink.sweep();
#Stop holding every page
The build used to render every page into memory, then write them all at the end. For 10,000 pages, that's a whole lot to hold in RAM!
Now each page goes to disk as soon as it renders, through a pool of 64 writes, and its body is dropped. Peak memory went from 1179 MB to 775 MB.
The interesting part was a leak that streaming exposed. bongo finds asset placeholders in each page with a regular expression. A regex match can share memory with the string it came from, so each small match kept the full HTML of its page alive. On 10,000 pages, that was 180 MB of a 253 MB live heap. The fix was to copy each match into a new string.
// bundler.ts
// before: each match shares memory with its page
export function placeholders(html: string): string[] {
return [...new Set(html.match(PLACEHOLDER))];
}
// after: each match owns its bytes
export function placeholders(html: string): string[] {
return [...new Set(html.match(PLACEHOLDER))].map((match) => {
return Buffer.from(match, "latin1").toString("latin1");
});
}
A short string is not always a small string.
#Stop doing work nobody reads
Two changes came from output that nobody needed.
After each build, bongo printed a size report. It read every output file, gzipped it on the main thread, and printed one line per file. On the bench, that took about 1.1 seconds and printed 10,108 lines. Nobody reads 10,108 lines. Above 50 files, the report now prints totals per extension, plus one line per script and stylesheet. It reads only those files. Everything else is sized from what the build already wrote, so it doesn't touch the disk again.
bench/site/public/ 10,105 files · 90.33 MB
.html 89.73 MB 10,101 files
.xml 583.55 kB 1 file
client.CJFnUKLj.js 9.82 kB │ 3.51 kB
main.DFNHBuxH.js 616 B │ 408 B
style.f6d61394.css 4.66 kB │ 1.34 kB
The other fix had to do with date formatting. Our $utils.date.format helper invoked new Intl.DateTimeFormat on every call, which is a slow/expensive constructor. It now keeps a cache of formatters (max 100), keyed by each language and its options set. This brought 20,000 calls from ~570ms to 17ms.
// config.ts
const FORMATS_MAX = 100;
const FORMATS: Map<string, Intl.DateTimeFormat> = new Map();
function formatter(lang: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
let sorted = Object.entries(options).sort(([a], [b]) => (a < b ? -1 : 1));
let key = JSON.stringify([lang, sorted]);
let hit = FORMATS.get(key);
if (hit) return hit;
let made = new Intl.DateTimeFormat(lang, { timeZone: "UTC", ...options });
if (FORMATS.size >= FORMATS_MAX) {
let [oldest] = FORMATS.keys();
FORMATS.delete(oldest);
}
FORMATS.set(key, made);
return made;
}
I wrote about this kind of fix in JavaScript performance tips I actually use: the slow part is often the setup, not the work.
#Rebuild only what changed
After those changes, a fresh build took 2.82 seconds. A build after a one-line edit also took 2.82 seconds, because every build was a fresh build.
Incremental builds were the big feature for 1.0. bongo build now keeps a memo of its last good build in node_modules/.cache/bongo/. bongo dev keeps it in memory. For each page, the memo knows the hash of its source, its frontmatter, what it read from other pages, its assets, and its output. A build renders a page again only when something it depends on has changed.
| build | files written | wall |
|---|---|---|
| no change | 4 | 239 ms |
| body edit | 5 | 248 ms |
| title edit | 205 | 337 ms |
| new page | 206 | 335 ms |
| layout edit | 10,105 | 2.17 s |
A body edit renders one page. A title edit renders more, because the 100 pages in its section link their neighbours by title, and every section index and the sitemap list every page. A layout edit renders everything, as it should. A build with no change still writes 4 files: the 3 bundles and the sitemap.
In bongo dev, a page edit went from 0.82 seconds to 80–90 ms.
bongo hashes every source file to find what changed, but it does not read every file to do it. When a file's size and mtime match the last build, bongo keeps its old hash and never opens the file. It misses only an edit that keeps both the same.* For that rare scenario, there is --fresh.
A file saved in the 2 seconds before a build is the exception. Its mtime may be too coarse to tell two saves apart, so the next build reads it no matter what.
* A normal edit cannot do this. Editors, git, and formatters all set the mtime to now. It takes a change that keeps the size, plus a tool that restores timestamps on purpose, like rsync -a, cp -p, touch -r, or git-restore-mtime.
// build.ts
async function scan(input: string, rel: string, name: string): Promise<void> {
let info = await fs.stat(input);
let note = (hash: string) =>
(next.sources[rel] =
info.mtimeMs < t0 - RACY ? [info.size, info.mtimeMs, hash] : [-1, -1, hash]);
let known = previous?.sources[rel];
if (known?.[0] === info.size && known[1] === info.mtimeMs) {
note(known[2]);
if (await recall(input, rel, name, known[2])) return;
}
await reads(async () => {
let bytes = await fs.read(input);
let hash = memo.hash(bytes);
note(hash);
if (await recall(input, rel, name, hash)) return;
// ...
});
}
#Correctness is the feature
An incremental build is only useful if you can trust it. If it's ever wrong, you stop trusting it, and you run --fresh every time, rendering the "incremental" feature (and all its instrumentation) a waste.
So the rule is strict. After every incremental build, the output must be the same bytes as a --fresh build. The test suite runs more than 13 steps of edits in both build and dev and compares every file. The incremental bench target checks the same thing on 10,000 pages.
// incremental.ts
for (let [name, edit] of Object.entries(EDITS)) {
for (let i = 0; i < runs; i++) {
let undo = await apply(dir, edit);
try {
[step.samples[i], step.written] = await written(() => sample(dir));
if (i === runs - 1) {
let output = await digest(out);
await sample(dir, ["--fresh"]);
if ((await digest(out)).sha !== output.sha) {
throw new Error(`${name}: the build wrote other bytes than a --fresh build`);
}
}
} finally {
await undo();
}
}
}
The drafts feature required the same treatment. A post with a future date is a draft until that date passes. That means a build can change despite no file change at all — the clock moved. The tests fix the clock, move it past the date between two builds, and check that the post appears. I also removed the draft flag from the memo's hash on purpose, to confirm that the tests fail when it is gone.
#Noise is as big as the gain
Near the end, the steps got small. Each one saved 5-15 ms on a 250 ms build. And rerunning the entire benchmark can swing by about 15 ms on its own...
When the noise is as big as the change, the normal method stops working. Run A, then run B, and you mostly measure what else the machine was doing. So the last steps were measured as an interleaved A/B: each version builds its own copy of the site, the two take turns, and each number is the median of 12 rounds. The machine had to be quiet, with a 1-minute load below 4, and not right after bun install, because macOS scans the new files and adds noise.
| step | no change | body edit |
|---|---|---|
| before | 309 ms | 315 ms |
| a full GC only after 1,000 pages | 294 ms | 301 ms |
| keep each page's registry digest | 280 ms | 287 ms |
| reuse sizes the build knows | 266 ms | 273 ms |
| one mark per asset, not one per page | 261 ms | 267 ms |
| replay kept pages in bulk | 246 ms | 257 ms |
| a smaller memo | 243 ms | 251 ms |
Then one change came out 15-21ms slower than the one before, with no reason in its code. An A/B against main showed a gap of 4-8ms. So I ran an A/A: the same code in both copies. It showed the same gap. The copy of the site made the difference, not the code. Without the A/A, that gap would have looked like a regression in the code. I wrote more about this in How to benchmark honestly.
#What did not work
Most ideas did not survive the bench. I think this list is as useful as the one above.
Structured clone for the memo. Bun can serialize values with JSC's structured clone. It sounded faster than JSON. Serialize took 49.5 ms against 5 ms for JSON.stringify, and deserialize took 17.7 ms against 17 ms for JSON.parse.
One big JSON.parse. The memo has one line per page. Parsing the whole file at once was no faster than parsing it line by line. The cost of JSON.parse follows the number of values, not the number of bytes.
A smaller memo. I cut the memo from 12.5 MB to 7.1 MB. It saved about 3 ms. For the same reason as above: fewer bytes, about the same number of values.
Listing public/ in the background. Total time went from 902 ms to 897 ms, which is noise. The work did not go away. It only moved and config loading got slower by the same amount.
A looser regex. To skip a scan for view transition attributes, I first used /\s+transition\:/. On 10,000 pages, it took 99 ms, because \s+ makes the engine backtrack at every space. It also matched the CSS property transition: in page text. Instead, /transition:(?:name|animate)/ takes 15 ms and matches only what it should. With that guard, a fresh build went from 2.87 s to 2.54 s.
// tags.ts
// before: 99 ms on 10k pages, and false positives
const TRANSITION_HINT = /\s+transition\:/;
// after: 15 ms
const TRANSITION_HINT = /transition:(?:name|animate)/;
export function rewriteTransitionAttrs(html: string): string {
if (!TRANSITION_HINT.test(html)) return html;
// ...
}
#Memory in dev
Speed was half of it. The other half was bongo dev on a large site, where the memory footprint swung between 460 MB and 1.46 GB and grew with each rebuild.
The cause was a closure. bongo made its $assets helpers inside the main run() function. Compiled templates keep their context until the next build and those helpers kept the whole scope of run() alive. That scope held the HTML of every page, twice: about 420 MB. Moving the factory out of run() fixed it.
Later, the same bug came back from a different line. A list of watched files, made with flatMap inside run(), held about 440 MB, even though the list was empty on the bench site. A bisect found the commit, and testing each part of that commit on its own found the line. A top-level function that makes the same list fixed it. I don't exactly know why, but my best guess is that closures made in run() keep its whole scope alive in JSC.
// build.ts
// before, inside run(): about 440 MB kept alive
let loads = [
...Object.values(next.items).flatMap((item) => item.page?.loads ?? []),
...output_loads,
];
// after, outside of run()
function loadedFiles(items: Record<string, memo.Item>, extra: [string, string][]): string[] {
let files = new Set<string>();
for (let rel in items) {
for (let [file] of items[rel].page?.loads ?? []) files.add(file);
}
for (let [file] of extra) files.add(file);
return [...files];
}
Before this work, the footprint after three full rebuilds was about 1.8 GB. In 1.0, it is 168 MB.
#Against Astro
The bench repo also has the same 10,000 pages built with Astro 7.4.1: the same layouts, table of contents, pager, tags, sitemap, stylesheet, script, view transitions, and prefetch.
| build | wall | peak RSS |
|---|---|---|
| bongo, fresh | 2.19 s | 506 MB |
| bongo, no change | 239 ms | 217 MB |
| Astro, fresh, Node 26.11 | 14.05 s | 2970 MB |
| Astro, fresh, Bun 1.4.3 | 12.04 s | 3082 MB |
| Astro, no change, Node 26.11 | 7.69 s | 2735 MB |
A fresh bongo build is about 6 times faster, with a sixth of the memory. With no change, it is about 32 times faster. Astro caches its parsed Markdown, but it still renders every page.
Disclaimer Astro does a lot that bongo does not. Astro highlights with Shiki whereas bongo uses twinkleplop. The Astro site is in the repo so that you can run it. The goal here is to compare the same site the way it would be built by the standard Astro and/or bongo setup.
#The whole path
| before | 1.0 | |
|---|---|---|
| fresh build | 6.86 s | 2.19 s |
| peak memory | 1183 MB | 506 MB |
| edit, then build | 6.86 s | 248 ms |
| dev page edit | 0.86 s | 90 ms |
None of these changes were hard on its own:
- Stop deleting files you're just going to create again.
- Stop holding onto things once you're done with them.
- Stop preparing & printing output that nobody reads.
- Do not rebuild what did not change.
The fastest code in this project is the code that no longer runs.