Three screenshots sit on the PubTrivia landing page, one after another. They were exported from the same app, at the same size, on the same afternoon. On disk they are 1.77 MB, 953 KB and 915 KB.
Over the wire, the first one costs about 10 KB and the other two cost 953 KB and 915 KB.
You can check all three yourself in under a minute, and the difference is not compression settings. It is which images the framework can see.
Measure it from outside
The three originals are in public/, so they are directly fetchable:
for f in demo-1 demo-2 demo-3; do
curl -s -o /dev/null -w "$f %{size_download}\n" https://pub-trivia.app/$f.webp
done
demo-1 1769688
demo-2 953385
demo-3 915548
Now ask for the first one the way the page asks for it, through the image optimiser at the width the layout actually uses:
curl -s -o /dev/null -w "%{size_download} %{content_type}\n" \
-H "Accept: image/avif,image/webp,*/*" \
"https://pub-trivia.app/_next/image?url=%2Fdemo-1.webp&w=1200&q=75"
10822 image/webp
1.77 MB becomes 10.8 KB. At w=640, which is what a phone gets, it is 4.2 KB. Load the landing page with the Network tab open and you can watch it arrive: the request is to /_next/image with a width in the query, not to /demo-1.webp.
Then scroll down and watch the other two arrive by their bare filenames, at full size.
The optimiser can only optimise what it is given
The first image is rendered by the framework's image component:
<Image
src="/demo-1.webp"
alt=""
fill
priority
className="object-cover object-top -z-10"
sizes="(max-width: 1320px) 100vw, 1320px"
/>
That produces a srcset of optimiser URLs, re-encodes to a modern format the requesting browser accepts, and resizes per breakpoint. The sizes attribute is what tells it the element is full width up to 1320 pixels and then capped, so a phone is never sent a desktop-width file.
The other two are rendered, inside a reusable row component, like this:
<div style={{ backgroundImage: `url(${backgroundImage})`, backgroundSize: "cover" }} />
There is no framework in that line. It is a URL in a style attribute, resolved by the browser's CSS engine, and every build-time image tool in existence is blind to it. No resizing, no format negotiation, no srcset. The browser fetches the file in public/ exactly as it was exported.
This is the part worth internalising, because it is not a Next.js quirk. Any build pipeline that optimises images by transforming your markup, which is all of them, optimises the images in your markup. A URL assembled at runtime inside a style object is not in your markup. The same blind spot catches background images in a CSS file with a variable, images set by JavaScript after an interaction, and anything behind a string template.
The preload that put 1.8 MB on every route
That alone would be a slow landing page. What made it a slow site was a well-intentioned optimisation in the root layout.
Those two background images load late by nature: the browser cannot discover a CSS background until it has built enough of the layout to know the element exists. The textbook fix is a preload hint, so somebody, me, put two tags in the root layout.
The root layout wraps every route. Not the landing page, every route. So every navigation anywhere on the site started by fetching 1.8 MB of landing-page decoration at high priority, including the one route where it hurts most: a player who has just scanned a QR code at a table in a pub, on venue WiFi shared with everyone else in the room, opening a page that has nothing to do with those images.
Preload is not a hint about importance, it is an instruction about priority, and the browser obeyed it. The images were fetched before things the current page actually needed.
Both tags are gone. What is left in their place is a comment explaining why nothing is preloaded there, which is the only kind of comment that reliably prevents a change from being made twice. The one image that genuinely is the largest contentful paint gets its preload from the framework automatically, because of that priority prop, and that preload is correct in a way a hand-written one is not: it preloads the optimiser URL at the right width, so it is 4 KB on a phone rather than 1.8 MB everywhere.
You can confirm there is nothing else being preloaded:
curl -s https://pub-trivia.app/ | grep -o 'rel="preload"[^>]*' | head
One result, a script, at fetchPriority="low".
What the remaining 1.8 MB is waiting on
The honest state of this: the two background images are still unoptimised. The fix is not a preload, it is to render them the way the first one is rendered, as a fill image behind the content rather than a CSS background, which puts them through the optimiser and makes them a few kilobytes each. The row component takes a URL today and would need to take an element instead.
The reason it has not happened yet is that the correct move came out of understanding the first one, and the lesson arrived in the wrong order: I reached for a preload before asking why these two images were discovered late while the one above them was not. The answer to that question was the fix. The preload was a way of paying for the answer every time.
If you take one habit from this, make it the measurement rather than the conclusion. Pull the raw file, pull the optimised URL, compare the two numbers. Any image on your own site where those numbers are close is an image your pipeline cannot see.
Everything above is checkable against the live site: the landing page with the three images on it, and the optimiser URL in the snippet above if you want the before and after side by side. The app the screenshots are of runs a live pub quiz, and the free tier does not ask for a card.
Top comments (1)
Great write-up, the curl comparison makes the problem impossible to argue with. One fix I've used for exactly this pattern is to keep the row component's API but render the framework image component absolutely positioned behind the content with object-fit: cover, so it looks like a background but still gets srcset and format negotiation. If a true CSS background is unavoidable, image-set() with AVIF/WebP candidates plus a media query for narrow screens at least stops phones from pulling the desktop file. Did you also check whether those two were being discovered late, since CSS backgrounds don't get picked up by the preload scanner?