A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.
A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.

Cutting the homepage from 5.7MB to 217KB

J
Jesse James Richard
|
Sep 1, 2026
|
23 min read
#Signature
#Architecture
#Testing
A screenshot of a Google PageSpeed Insights report for a website URL.
A digital dashboard showing performance, accessibility, best practices, and SEO scores for a mobile website.

Ten days on the public site. Images on the homepage went from 5,743 kilobytes to 217. The JavaScript every route loads went from 4,930 to 2,667 kilobytes minified. Layout shift on desktop went from 0.5 to 0.0015, and on mobile from 0.63 to 0.146.

Four of the five root causes I was confident about turned out to be wrong. Those are in here too, because a hypothesis that got disproven tells you what to measure before you spend a day on it.

0%

fewer image bytes

0%

less JavaScript

0.0%

less layout shift

What each metric measures

Lighthouse and PageSpeed give you one number on the front, which is an average of several. The number is not useful. The individual metrics are.

First Contentful Paint. The moment the browser puts anything on screen. Any text, any image. It tells you how long the visitor stares at a blank page. It does not care whether what appeared is the thing they came for, or whether it is in the right font.

Largest Contentful Paint. The moment the biggest element in the initial viewport finishes rendering. Usually a hero image, a heading, or a block of text. This is the closest thing to when the page feels ready. It is scored hard, and on most sites it is the metric that decides your number.

Cumulative Layout Shift. How much things move on screen after they have appeared, weighted by how much of the screen moved and how far. This is the metric that punishes you for a button that jumps out from under a finger. It counts only visible movement. Content that is not visible cannot shift.

Total Blocking Time. How long the main thread was busy enough to ignore a click. Long JavaScript tasks are the usual cause. A page can paint quickly and still be dead to input for several seconds.

Two of those are about bytes arriving. Two are about what the browser does once they have. They fail for different reasons and are fixed in different places, so start by finding out which of them is actually bad instead of treating the summary score as one problem.

Step 1

Images requested without a size

Every image served at full resolution as a raw PNG, because the two functions that build image URLs never asked for a variant. Three edits halved the homepage.

Step 2

The backfill nobody had run

Sixty-one images had no variants at all, left over from a processing outage that outlasted its retries. A nightly reconcile job took the homepage to 217 kilobytes.

Step 3

The bundle, for the wrong reason

The config flag I expected to fix it changed nothing. The cause was an eager block registry and a missing side-effect declaration, and the fix cut the universal load by 46 percent.

Step 4

Content hidden until JavaScript ran

Entrance animations were hiding server-rendered content until JavaScript ran. Ungating the top of the page took the largest paint from 9.8 seconds to 6.0.

Step 5

The paint fix exposed the shifts

Layout shift went from 0 to 1.011 on the same deploy. Nothing new broke. The score had been zero only because the content was invisible, and every shift had been there all along.

Step 6

Twenty-three boundaries reserving no space

Two confident diagnoses were disproven by measurement. The cause was a suspense boundary with a null fallback on every block, deferring each to zero height. Desktop shift finished at 0.0015.

Step 7

The fonts that never applied

Three loaders, none of them putting the primary font on the critical path, so the site rendered in Arial while the score improved. Thirty-eight fonts are now self-hosted with metric-matched fallbacks.

Where the bytes were

Before optimising anything, find out what the page actually downloads. You do not need a tool for this.

Fetch the page, pull out every script tag, and sum the sizes of the files they point at. Do the same for the images. Two numbers, five minutes, and they tell you which half of the problem you have. Do it against production rather than your development build, because a development build is a different program.

On our homepage the answer was images, by a factor of three or four over everything else. Total payload 6.6 megabytes. PageSpeed put 2,361 kilobytes under improvable image delivery and another 3,983 under cache lifetimes.

The images were being served completely raw. Original PNGs, full resolution, sent to a phone and a desktop alike, with no WebP and no smaller sizes. The cache header said one hour, on files that are addressed by a content id and can never change, which meant a returning visitor re-downloaded several megabytes every hour for no reason. And none of the image tags carried a width or height, which is a layout shift waiting to happen.

Those megabytes are paid for by the visitor, on their connection and their data plan, before they have read a word. A phone on a slow connection is the visitor most likely to leave.

The image pipeline was already built

I filed the work as build an image transform in the files service. That was wrong, and finding out took an hour of reading rather than a day of building.

The transform already existed. On upload, the worker ran every image through sharp, produced thumbnail, small, medium and large WebP variants, skipped any size larger than the original, uploaded each one with a one-year cache header, and recorded the set on the file row. It also ran a classifier and stored alt text. The route that serves files already accepted a size parameter and would return the matching variant.

Nothing ever asked for one. Two functions turn a stored file reference into a URL, one for the server render and one for the client renderer, and both emitted the bare file path with no size parameter. Every visitor got the original. Nothing read the alt text either.

The fix was three edits. The serve route now walks down from large through medium and small to thumbnail, returns the best variant it finds, and never falls back to the raw original unless the file has no variants at all. Both URL builders request a size. One hero image went from a 1,705 kilobyte PNG to a 42 kilobyte WebP, forty times smaller, and the homepage halved immediately.

Before building a capability, check whether it exists and is simply not being called. A pipeline that runs on upload and a consumer that never requests its output will sit next to each other indefinitely, because both halves work and neither is wrong on its own.

Sixty-one images with no variants

Half the payload was still there after that, and it was one image: a hero background that predated the pipeline, had no variants, and so came back as the original 2.5 megabyte PNG. Behind it were sixty more.

The obvious reading is a bug in the upload path. It was not. Every image uploaded since the tenth of the month had variants, and both the upload and the generation paths enqueue processing correctly. Forty-one of the sixty-one came from one week.

They were left over from a processing outage. Variant generation is queued and forgotten, with three retries. Three retries covers a momentary failure. It does not cover a service being down for hours, and when the retries were exhausted the images stayed raw permanently, because nothing ever went back to look.

The fix is a scheduled job that finds images with no variants and re-queues them. Run once it is a backfill. Left on a nightly schedule it repairs the next outage without anyone noticing there was one. Sixty-one of sixty-one completed, zero failed. The homepage dropped to 217 kilobytes, and the 2.5 megabyte hero now serves as a 17 kilobyte WebP.

The job looks for images whose variant record is null, not images whose record is empty. Twelve images on the site are small enough that every variant size would be larger than the original, so the pipeline correctly produced none and wrote an empty record. Those are finished, not broken. A reconcile job that cannot tell the difference will re-queue them every night forever.

Any asynchronous processing step with a retry limit needs something that comes back later and checks. Without it, every outage leaves permanent damage sized to the length of the outage, and you find it months later while investigating something else.

Measuring the right JavaScript

The JavaScript half took longer, and most of the delay was that I was measuring the wrong set of files.

There was a bundle guard in the build, and it was green. It measured the framework's shared entry files and reported 446 kilobytes, comfortably inside budget. Meanwhile a three megabyte charting library was loading on every route, including pages with no charts on them.

The shared entry files are not the set every route loads. In a modern app router build, each route has a manifest listing the client modules it needs, and what matters is the intersection of those manifests, the code that loads no matter which page a visitor lands on. That is the number a budget should guard, and it was not the number being guarded.

So: measure the universal load from the route manifests, not from the framework's entry chunks, and add an explicit list of libraries that must never appear in it. A budget on a total size will drift up a few kilobytes at a time and never fire. A rule that says a charting library, a syntax highlighter and a data grid may not appear in the universal load will fire the moment one is imported somewhere careless.

The guard was not broken. It was correct about the thing it measured, and I had chosen the wrong thing to measure.

What did not work on the bundle

My hypothesis was the component barrel. Our shared package re-exports an entire component library with a wildcard export, and the build config set no import optimisation, so nothing could tree-shake and the whole library landed in one chunk. It is a well-known problem with a well-known one-line fix.

The one-line fix produced no change whatsoever.

To test a config flag properly, build twice, once with and once without, each into its own output directory so neither can read the other's cache. Compare the chunk count, the total bytes, and the largest few chunks. Ours produced an identical 372 chunks and a byte-for-byte identical total. Not a marginal gain. Zero.

The real cause was in two places, neither of them the barrel.

The first will hit anyone building a page builder or a plugin system. The renderer looks up a block by its type in a registry object, and that object held both the view component and the editor settings panel for every block type. Because the lookup key is a variable rather than a literal, a bundler cannot know which entries are reachable, so it keeps all of them. Every visitor to a marketing page downloaded the entire block palette, charts and syntax highlighter and authentication SDK included, plus one hundred and eighteen editor panels that only exist inside the admin console. The fix was to make each block's view and settings lazy at the point they are declared, so the registry holds references rather than implementations.

The second was a single line of package metadata. Our shared package declared no side-effect information, which tells a bundler that importing anything from it might have consequences, so it cannot safely drop the unused parts. Declaring that only the stylesheets have side effects let the barrel finally tree-shake.

Together: 4,930 kilobytes down to 2,667 minified, 689 gzipped on the wire, with the charting library and the syntax highlighter gone from the universal load entirely. The lazy work took two days and the one line took a minute, and the one line is what cut the bytes.

Content hidden until JavaScript runs

With the bytes dealt with, the paint was still slow: 9.8 seconds to render the largest element, against a target of 2.5.

The cause was an entrance animation. Every section and every block was wrapped in a component that sets opacity to zero and visibility to hidden until the element scrolls into view. The hook that decides whether something is in view starts as false, so the server-rendered HTML and the first client render were always the hidden state. The homepage shipped forty-six elements at zero opacity and seven hidden outright, the hero among them.

The content was in the HTML the whole time. The browser had it, could have painted it, and was waiting for JavaScript to download, parse and execute before it was allowed to. Roughly four of those 9.8 seconds were that wait.

There was a second cost that no score reports. With JavaScript disabled or failed, the site was blank. Not degraded. Blank. That includes anything that fetches the page without running scripts, which is a category that matters more every year.

The fix is to make the hidden state apply only below the fold, so above-the-fold content renders visible on the server and the entrance effect still runs for everything a visitor scrolls to. Paint went from 9.8 seconds to 6.0.

This is worth checking on any site with scroll animations, and it is easy to check: disable JavaScript and load the page. Whatever you see is what a browser can paint immediately. If it is empty, your animation library is deciding your paint metric.

Fixing paint broke layout

Layout shift had been sitting at zero through all of this. After the animation fix it was 1.011. The recommended maximum is 0.1.

Nothing new broke. Layout shift only counts movement the visitor can see, and until that deploy the content was invisible until JavaScript ran. Every shift on that page was already happening behind an opacity of zero, so it counted for nothing. Making the page paint early made all of it visible at once.

Expect this trade if you fix a paint problem of this kind, so it does not look like a regression from the wrong commit. The answer is not to revert. A page that paints in six seconds with visible shifts is worse than one that paints in ten with none, but a page that paints in two with none is available, and the way to it goes through this stage.

A metric sitting at a perfect value is worth checking rather than celebrating. Zero shift means either an unusually disciplined page, or a page nobody can see yet.

Diagnosing a layout shift

Layout shift is the hardest of the four to diagnose, because the report names a victim and you need the origin. Here is the method that worked, and then the two wrong answers it ruled out.

Load the page in a real browser and read the shift entries directly. The observer must be created with buffering enabled, because the entries are recorded during load, before any observer you attach afterwards exists. Without that flag you get an empty list and conclude there is no problem.

Reading the shift entries

const po = new PerformanceObserver(() => {});po.observe({ type: "layout-shift", buffered: true });const entries = po.takeRecords().filter((e) => !e.hadRecentInput);entries.reduce((total, e) => total + e.value, 0);

Each entry lists the elements involved, with the rectangle each occupied before and after. That pair is the whole diagnosis. An element whose height changed is a cause. An element whose height is identical but whose vertical position moved is a victim, pushed by something above it. Look upward from the victims until you find something that changed size.

Our production report named one specific section as the dominant source, and I spent real time on it. It was the only element on the page with an id attribute. Shift attribution can only report what it can name, so a section with an id will be named ahead of the anonymous div that actually caused the problem. Its height never changed. It moved because everything above it did.

First wrong answer. A transition on a layout property, animating height as the page settled. It looked right in the computed styles. Its computed duration was zero, inherited from a theme class on the document element, and a zero-duration transition animates nothing. Shipping a fix for it would have changed no bytes and no behaviour.

Second wrong answer. A hydration mismatch on breakpoints. Several blocks use a media-query hook that returns false during server rendering, so the server paints a desktop layout and the client corrects to mobile after mounting, re-laying out the whole page in one frame. It fit the evidence well: a single large shift entry around eight hundred milliseconds in, with several unrelated elements changing height together. Converting the typography scale to CSS media queries moved mobile shift from 0.1459 to 0.1459.

The actual cause. The renderer wrapped every block in a suspense boundary with a null fallback. A null fallback reserves no space, so the framework deferred each block into a later streaming chunk and it rendered at zero height before snapping to full size. Twenty-three deferred boundaries on the landing page, two of them inside the hero. Removing that one wrapper took desktop shift to 0.0002.

The blocks are not code-split and the registry is static, so nothing there could ever suspend. The boundary had been added to contain render failures, and an error boundary nested directly inside it was already doing that. If you use a suspense boundary for isolation rather than for loading, give it a fallback that occupies the same space as the content, or do not use one.

Desktop finished at 0.0015, mobile at 0.146. The mobile remainder is the breakpoint mismatch from the second wrong answer, which turned out to be real but small.

Why the site rendered in Arial

With the shifts fixed and the fonts reworked, the mobile score went from 36 to 66 and desktop reached 96. The site was also rendering in Arial.

Three separate mechanisms were loading fonts and none of them put the primary one on the critical path.

The intended font was imported as a bundled stylesheet, so its font-face declaration ended up inside a JavaScript chunk instead of the document head, and its file was never preloaded. The browser could not know it existed until it had executed enough JavaScript to find it.

A second and slightly different font family was loaded from Google using a common trick: request the stylesheet with a print media type so it does not block rendering, then flip it to all in a load handler. It also used a display strategy that gives the font roughly one hundred milliseconds to arrive before permanently locking in the fallback. A stylesheet loaded asynchronously will miss a hundred millisecond window essentially always, so the fallback was what visitors saw, permanently.

A third loader injected that same Google font again at runtime when a theme provider mounted.

And there was no metric-matched fallback anywhere. A fallback font with different letter widths and line heights means that when the real font finally applies, every line of text on the page changes size and everything below it moves. That is both the visible jump and a contribution to shift.

The fix was to self-host all thirty-eight fonts a tenant can choose, through the framework's font pipeline, which preloads the file, inlines the declaration in the head, and generates a size-adjusted fallback automatically. The flash and the reflow are both gone, for any tenant's font rather than only the default. The Google requests are gone with them, which also removes a cross-origin round trip from the critical path.

One bug inside that work is not obvious. Deduplicating the runtime font links, the first attempt looked for an existing link with the same address and skipped if it found one. It found the server-rendered link parked at print media, waiting for its load handler to flip it live. React does not fire a load handler for a server-rendered element whose resource finished loading before hydration attached the handler. So that link never flipped and applied nothing, and the runtime injection had been silently compensating for it. Deduplicating removed the compensation and the fonts disappeared everywhere except the header. Treat only an applying stylesheet as satisfying a request, not a parked one.

The score went up and the page got worse

The font problem was found by looking at the site. Not by an alert, not by a test, and not by any of the instrumentation I had built during the previous nine days.

At that moment the measurements reported a layout shift of 0.0015 and exactly one font stylesheet request. Both correct. Both the numbers I had specifically chosen to watch. And every heading and paragraph on the site was in the fallback.

The instrumentation was measuring layout stability and request counts. The failure was which typeface applied, and no amount of care with the first two would have surfaced the third.

That was the third time in ten days. Layout shift read zero while the content was invisible. The bundle guard read 446 kilobytes while three megabytes leaked into every route. The font instrumentation read clean while the page was in Arial.

So for anything with a visual surface: take the measurement, then look at the rendered page. Automated screenshots on a couple of representative pages cost very little and would have caught all three of these.

The numbers

Same pages, same method, before and after.

Before
After

Homepage image payload

5,743 KB

217 KB

JavaScript on every route

4,930 KB

2,667 KB

Largest paint, desktop

9.8s

6.0s

Layout shift, desktop

0.5003

0.0015

Layout shift, mobile

0.63

0.146

The paint figure is the one that is not finished. It is measured on desktop after the animation fix and before the preload work described below, and mobile is considerably worse than it for reasons that are understood and not yet resolved.

What to check on your own site

In rough order of how much time each is likely to buy you. None of these needs a tool you do not already have.

Fetch your production homepage and sum the image bytes and the script bytes separately. Five minutes, and you will know which half of the problem you have. Most sites have the image one.

Check that images are served in a modern format at a size close to how they are displayed. A 1,600 pixel image in a 377 pixel slot is common and costs more than any code change will save you.

Check the cache headers on static assets. If a file's address changes when its contents change, it can be cached for a year. Content-addressed files on a one-hour cache make every returning visitor pay again.

Load the site with JavaScript disabled. What renders is what a browser can paint immediately. What does not render is gated behind your bundle.

Check that every image tag has an explicit width and height, or an aspect ratio. Without one the browser reserves no space and everything below it jumps when the image arrives.

Find out what your largest paint element actually is rather than assuming. If it is a background image on a div, nothing can discover it until the CSS is parsed and the element computed, and it needs an explicit preload.

Verify what your guards measure. If you have a bundle budget, check which file set it reads. If you have font loading tricks, check which font applies on the rendered page. The answer is often not the one the configuration implies.

What is still open

Mobile is at 70. Accessibility is 96, best practices 100, SEO 100, and the agentic browsing check passes both of its audits. Performance is the only category not close to finished, and one metric is holding it down.

The largest paint element is a div whose background image is the hero, around 200 kilobytes, and there is not a single preload or preconnect hint in the document head. The browser cannot begin fetching it until it has parsed the CSS and computed that element. On a fast connection the same image paints at 1.4 seconds; on a throttled mobile profile it takes 13.4. The gap is almost entirely discovery.

A preload for the first above-the-fold image is built and not yet measured. It preloads exactly one image, the hero background rather than an earlier logo, because on a constrained connection preloading the wrong image takes bandwidth from the right one.

Behind it is a larger piece. The correct image size and the correct breakpoint layout need the same missing information: the server knowing the size of the display. The files service serves four variants and is never told which to send, so the client asks for the largest. The layout has values for every breakpoint and the server guesses desktop, so the page re-lays out after hydration. One detected viewport, from client hints with a user-agent fallback, feeds both and resolves the oversized downloads and the layout flip together.

That is built and deliberately unmeasured until the cache policy and the variants are both live, because measuring half a change is how at least two of the wrong answers above got believed.

Three smaller things remain open. Replacing a file writes to the same variant path, so a replaced image can serve stale for up to a year against the one-year cache; the fix is to fingerprint the variant paths so a replacement is a new address. Uploads through the signed-URL path still land with a one-hour cache, because setting the header there requires it to match a signature and a stale browser tab would break, so the existing objects were corrected in place instead. And the alt text the classifier already generates is still not reaching the rendered image tags, which needs file metadata plumbed through to the renderer and is an accessibility gap rather than a performance one.

The remaining mobile shift of 0.146 is the breakpoint mismatch. Every block's margin, padding, width and column span resolves against a breakpoint computed in JavaScript, so the server renders desktop values and the client reflows the page after mounting. Fixing it means the styling layer emits media queries instead of resolving to a single value, which touches every block in the renderer. It is a genuine refactor and it will get its own before and after.

Compute gets cheaper per request at scale

#Architecture
#AI & Agents

I price Giant Context at $25 per million tokens and had no defensible answer to what it costs to serve a thousand customers. Four of the five cost cla...

Jesse James Richard

|

Aug 30, 2026
Read previous

Building something like this

I'm Jesse. I build platforms end to end, and I'm open to work. If this is the kind of engineering you need, get in touch.

Contact Jesse
Home
About
Contact
Sitemap
Privacy Policy
Terms of Service
Cookie Policy
Cutting the homepage from 5.7MB to 217KB | Jesse James Richard