In September a design review of this site recorded a figure I was pleased with: the homepage was 7 requests and 18.1KB. No webfonts, one third-party script, card images lazy-loaded. I put “an 18KB page” in the backlog as an article about treating a performance budget as a design constraint.

Before writing it, I measured again. Every number below is from 3 October 2026, a cold load in a fresh browser context against the live site.

Requests Transferred
Homepage, desktop (1280px) 11 42.3KB
Homepage, phone (390px at 3×) 8 45.9KB
A second page, same visit 8 21.8KB

So this is not the article I planned. It is about where 24KB came from in two weeks, and why the largest single item on the page is one I nearly did not count at all.

What the page is made of

On the desktop load:

Item Transferred Decoded
HTML 8.3KB 32.1KB
CSS, two hashed files 12.6KB 54.0KB
Images: portrait and four card diagrams 10.5KB —
Analytics script, third-party 9.9KB 29.6KB
Favicon and an analytics ping 0.9KB —

Most of that is the result of decisions that still hold.

Diagrams are SVG, generated at build time. Every card on the site is drawn by a script into a small SVG; on these pages they were between 1.2 and 3.5KB compressed.

The one photograph is resized and re-encoded at build. The source portrait is an 80KB JPEG. Astro’s <Picture> emits AVIF and WebP at four widths, and the browser picks one: 3.3KB at desktop density.

No framework runtime. On 3 October the page shipped four small inline scripts — theme selection, the menu and a couple of enhancements, about 5KB before compression — and nothing else of its own. There is no hydration bundle, because nothing on the page needs one.

Assets are cached forever, honestly. Every CSS file has a content hash in its name, so it is served with Cache-Control: immutable for a year. The previous header made every navigation revalidate the stylesheet for nothing. That is why the second page costs only its own HTML and images: 21.8KB, with no CSS at all.

Where 24KB came from

Two things changed between the review and the re-measurement, and they are different kinds of change.

The first is the page itself. In the days after the review it changed several times — a brighter summary panel, a section explaining how the site is built, a “Now” line — and each change was reasonable on its own. Nothing compared the total against a number.

The images are the part I can see most clearly, and they show how much the viewport matters. The review noted nine lazy-loaded card images; today’s homepage has five images in total, fewer than before. But at desktop width all five load immediately, because the lazy-loaded cards sit close enough to the first screen that the browser fetches them at once. On a phone, only one card does — and the portrait is fetched at triple density instead, which is why the phone pays more than the desktop. The review did not record its viewport, so I cannot say how much of the difference is the page and how much is the window it was measured in.

The second is not growth at all.

The item in-page measurement cannot see

The quickest way to weigh a page from inside it is the Resource Timing API: every request, with its transferred size. I used it first, and it reported 32.4KB. The analytics script was in the list, with a size of zero.

It is not zero. It is 10,125 bytes over the wire, gzipped, and 29.6KB once decoded — the largest single item on the page. The browser hides its size because it comes from another origin that does not send a Timing-Allow-Origin header. Without that header, Resource Timing deliberately reports a cross-origin request’s sizes as zero, so a page cannot learn things about other origins it has no business knowing.

That is a sound privacy rule, and it means any budget measured from inside the page undercounts exactly the items you control least. A third-party script is the thing most likely to grow without your knowledge, and the one your page-weight script will report as free.

I do not know whether September’s 18.1KB counted the script’s bytes. The review counted it as a request; its notes do not say how the size was measured. That uncertainty is itself the lesson: a number without its method cannot be compared with anything.

The fix is to measure where the bytes are visible — the browser’s network panel, a performance trace, or a request from outside the page:

curl -s -o /dev/null -w "%{size_download} bytes\n" -H "Accept-Encoding: gzip" \
  "https://static.example-analytics.test/beacon.min.js"

The content security policy is the inventory

There is a second place third parties show up, and it cannot undercount. The site’s content security policy allows scripts from exactly two places: the site itself, and one analytics origin. Any new third-party script has to be added to that header, in a reviewed change, or the browser refuses to run it.

That makes the policy a better inventory of third-party weight than any measurement. If the list of allowed origins does not grow, the third-party share of the budget cannot grow by adding a vendor — only by the vendor growing its script.

The same header shows the one trade-off inlining makes. Those small inline scripts save requests, and until recently they were why the policy also allowed 'unsafe-inline' for scripts — any inline script at all, including an injected one. Bytes saved in one budget were spent in another. The policy now lists the SHA-256 hash of each inline script instead, computed from the built pages on every build, so the allowance covers exactly the scripts the site ships and nothing else.

A budget is a decision about each item

What I would do differently is not a smaller number. It is writing the budget down per item, with how each is measured:

  • HTML: under 10KB compressed for the homepage, measured from the network.
  • CSS: one shared, immutable bundle; what the first visit pays, every later page gets free.
  • Images in the first view: a fixed count at a named viewport, not just a size — lazy loading means the count depends on the width.
  • Scripts of the site’s own: each bundle named, with its measured size, the pages that load it, and who downloads it without using it.
  • Fonts: each file named, with its measured size, and cached as long as the stylesheet.
  • Third parties: listed in the content security policy, each with its measured size and a reason.

That last line turns the 9.9KB analytics script into a decision instead of an accident. A page-view count is worth some bytes. Whether it is worth a quarter of the page is a question I can now answer on purpose — and check again whenever the number moves.

The next day, the page got heavier on purpose

The day after these measurements, I added a webfont. Headings had been set in the platform grotesque, so the loudest type on the site was Segoe UI on Windows, San Francisco on a Mac and something else again on Android: it belonged to the reader’s operating system rather than to the site. The replacement is Archivo, served from the site itself, Latin characters only, with every weight in one variable file of 34.1KB. Body text stays on the system face.

Measured the same way on 4 October, the desktop homepage is 12 requests and 77.5KB, counting the analytics script. The font became the largest single item on the page, about three and a half times the size of the analytics script the rest of this article is about.

That is the budget doing its job, not failing at it. The font has a line of its own:

  • Measured: 34.1KB, the same over the wire as on disk, because a woff2 file is already compressed.
  • Same origin: the content security policy already allowed fonts from the site itself, so the header did not change.
  • Preloaded: the browser starts fetching it alongside the stylesheet rather than after it.
  • Cached for a year: like the stylesheet, the first page pays for it and every later page gets it free.

Whether 34KB of type is worth it is exactly the question the budget exists to ask. I decided it is. The difference from the first 24KB is that this time it was decided with the number in front of me, not discovered two weeks later.

Six days later, it nearly doubled

On 10 and 11 October the site gained a motion layer: smooth scrolling, cards and sections that rise into place as they scroll into view, headings that lift in line by line, a light that follows the pointer across cards, and a dot grid behind the homepage introduction. It runs on GSAP and Lenis, bundled with the site’s own code.

Measured the same way on 20 October, the desktop homepage is 16 requests and 143.8KB, counting the analytics script. That is 66KB more than on 4 October, and almost all of it is four new scripts:

Item Transferred Decoded
Motion code, every page unless reduced motion is set 55.6KB 142.1KB
Its loader and the diagram lens, every page 1.5KB 2.8KB
Dot grid, homepage at desktop widths 4.7KB 10.7KB
Its loader, homepage at desktop widths 0.3KB 0.5KB

The motion code is now the largest single item on the page: 1.6 times the font, and more than five times the analytics script. That script grew as well, from 10,125 to 10,264 bytes, which is the only way the section on the content security policy said it could.

None of the commits that added the motion layer recorded its size. The number turned up when this article was checked before publishing, a week after its own budget had been written down. That is the failure the article describes: a run of reasonable changes, each one small, with nothing comparing the total against a number.

Whether it is worth it is the same question the font raised, and I decided it is. It has a line of its own now:

  • Measured: 55.6KB compressed and 142.1KB decoded, the largest item on the page.
  • Same origin: it is bundled with the site’s own code, so the content security policy did not change.
  • Clear of the first screen: it is fetched by a deferred module script and never hides anything already in view when the page loads. After load at 1280px and at 390px, nothing in the first screen was left invisible.
  • Cached for a year: it is immutable, so like the stylesheet it is paid for once.
  • Not paid for by readers who turned motion off: the first version was a static import, so a reader whose system asks for reduced motion downloaded the whole bundle and then started none of it. Writing this line is what showed it, and it was fixed the same day. The motion code now loads only when reduced motion is not requested, and the diagram lens, a reading aid that runs either way, moved into the small loader. With reduced motion set, the desktop homepage is 15 requests and 88.0KB. The dot grid had the same problem on a phone, where CSS hides it, and now loads only at desktop widths.

That last line is the one a budget exists to surface. The font was weighed and then decided. The motion was decided, shipped and weighed afterwards, and the weighing changed it within the day.

Lessons

A page-weight number needs its method next to it. Two weeks apart, two measurements disagreed, and one of the reasons was not knowing how the first was taken.

In-page measurement cannot see third-party bytes. Without Timing-Allow-Origin, Resource Timing reports cross-origin sizes as zero. Measure from the network.

The content security policy is the honest inventory of third parties. It cannot undercount, and adding to it is a reviewed change.

Growth arrives one reasonable decision at a time. Each change to the page was sensible; nothing compared the total against a budget.

A deliberate addition is still a line in the budget. The font was the right call and the largest item on the page. Both are true, and the budget is where they are written next to each other.

Writing a budget down is not the same as checking it. The motion layer arrived a week after this budget was written and was weighed only when the article was.

Hashed, immutable assets make the second page cheap. The stylesheet is paid for once.

Budget the count of first-view images, not just the bytes, and name the viewport. Lazy loading behaves differently at every width.


Measured on 3 October 2026, again on 4 October after the font, and on 20 October after the motion layer, against the live site with Chrome’s network panel and Resource Timing, from fresh browser contexts, cache disabled by isolation; the cross-origin script was weighed from outside the page. The figures will have moved by the time you read this; the method is the point.