What the budgets caught
How measuring first changed the notes filter, the home page's largest paint, the fonts and the budgets.
The first note showed the budgets, a test that fails when a page gets too big. The limits in that test came from measuring the site, and the measuring changed a few things first. This note is about what the numbers showed and what changed because of them.
Two scripts
The rule in this project is to measure before optimising, so it has two scripts for that. bun run size lists every page with its HTML, CSS, JavaScript and fonts, raw and compressed. bun run perf loads the pages in a headless Chromium and reports the largest contentful paint (when the biggest element appears), layout shift and blocking time. It does that once on a desktop and once on a phone profile with a four times slower CPU and a slow 4G network.
The notes filter
The size table showed the first problem, on the notes page. Its type filter (all, write-ups, TIL) was an island, a React component that hydrates in the browser, so a page that only lists links loaded 67 KB of JavaScript just to hide some rows.
But hiding rows does not need JavaScript. The filter is now a group of native radio buttons, and the stylesheet checks which one is selected with :has(), a selector that matches an element when something inside it matches:
.notes-browser:has(#notes-filter-til:checked) .note-row:not([data-type="til"]),
.notes-browser:has(#notes-filter-til:checked) .notes-year:not(:has(.note-row[data-type="til"])) {
display: none;
}
When the TIL radio is checked, every note of another type is hidden, and so is every year that has no TIL in it. The notes page now loads 0 KB of JavaScript, and a browser without :has() simply shows all notes.
The home page's largest paint
On the throttled phone, the home page took 6.2 seconds for its largest paint, because of the terminal. After hydration (React attaches its event handlers to the HTML that is already there) it replays a short intro, and the replay used to create its rows one by one. The browser keeps reporting a new largest paint whenever a new, larger element appears, and by then the web fonts had loaded. So a row created during the replay was reported as the largest paint, seconds after the page was ready.
That's why the replay now starts from every row of the finished intro and marks the rows it has not reached yet as pending. The component that draws one row turns that flag into a CSS class:
const rowClass = row.pending ? "terminal__intro-row terminal__intro-row--pending" : "terminal__intro-row";
The class does nothing but hide the row:
.terminal__intro-row--pending {
visibility: hidden;
}
With display: none a row would have no box until the replay reaches it, and showing it would create a new element for the browser to report. With visibility: hidden the rows are there at first load with their final size, and the replay only makes them visible. On the phone profile, the largest paint went from 6.2 to 1.6 seconds.
Fonts without layout shift
The fonts are hosted here and swap in when they have loaded. Until then the browser shows a local font, and if that font is wider or taller, the text jumps when the real one arrives. So the site defines fallbacks that are scaled to the size of the web fonts:
@font-face {
font-family: "Space Grotesk Fallback";
src: local("Arial"), local("ArialMT"), local("Liberation Sans");
size-adjust: 109.25%;
ascent-override: 90.06%;
descent-override: 26.73%;
}
The percentages were measured in Chromium against the web font files, with canvas measureText at 1000px on an English sample text, so Arial takes the same width, ascent and descent as Space Grotesk.
Budgets from the measurements
After these changes, the numbers became the budgets. The notes, a note, imprint, privacy and 404 load no JavaScript, and the test fails if one of them ever loads a file. The home page measured 82.6 KB and the contact page 70.9 KB, and about 66 KB of each is React. Their budgets are the measured size plus about 5%, so they catch growth, not the size itself:
js: {
"/": 87 * KB,
"/contact/": 75 * KB,
},
I also measured an experiment that builds the islands with Preact through preact/compat. The home page came to 24.4 KB and the contact page to 13.1 KB, and all islands still hydrated without warnings. The islands still use React, so the Preact numbers sit in a comment next to the budgets. If the stack changes, both limits drop to the new size plus 5%, about 26 KB and 14 KB.