The front page on a phone is a stage select: one place’s photograph filling the screen, turning to the next every three seconds. It was reported to me as not turning. Two separate things were wrong, and the second one is the one worth writing down, because I would not have found it by reading the code.
Some background on the arrangement. The landing ships both front pages in the HTML, the phone one and the desktop one, and a media query on pointer type shows one of them. That is deliberate rather than lazy: the pointer type is only knowable on the client, so branching in JavaScript means the server renders one and hydration swaps it, which is a flash and a reflow on the first screen every visitor sees. A media query costs neither.
The catch is that display: none is not a fetch gate. An image in a hidden subtree still downloads. I knew that, and the codebase already had the fix in one direction: the phone front page carries the pointer test inside every <source> element, so a desktop resolves them to nothing and never pulls frames it will not paint. That was added after a desktop was measured pulling 542 kB of them.
Nobody had ever run the same test the other way around. Measured on a 390 by 664 viewport at device pixel ratio 3, a phone pulled all eleven of the desktop front page’s photographs, 1.87 MB at 1170 pixels wide, for a page sitting inside a hidden div that it never paints. The gate existed, the measurement that justified it was written down next to it, and the mirror of it sat undone.
Now the part I did not expect. The wasted bytes were not what the visitor was feeling. They were requested all at once, in parallel, and the phone front page does not start a place’s three second hold until that place’s photograph has arrived. So the hold is three seconds plus a download, and the one photograph it needed next was queued behind ten it did not. Throttled against the live site: a 1.6 Mbps link stretched one hold to 7.5 seconds, and a 0.4 Mbps link stretched one to 30 seconds. Thirty seconds on one photograph is not a slow carousel. It is a carousel a visitor reads as broken, which is exactly how it was reported.
That is the general lesson I am taking. A hidden element that fetches is usually filed as waste, and waste gets fixed when somebody is tidying. The actual damage here was contention: eleven requests the page did not need, starving one it did, on the only connection where it mattered. Nothing about the symptom pointed at the hidden page, and the hidden page is invisible by construction.
The fix in the frame is the mirror of the gate that already existed, so a coarse pointer resolves those sources to an inline 1 by 1 transparent GIF: two photographs at load instead of eleven. With the pipe clear, the set is then pulled deliberately rather than accidentally, one request at a time, starting only after the lead photograph has painted, so it never races the first paint and never has more than a single frame of bandwidth in flight. Measured with photographs arriving slower than the hold itself, which is the case the old arrangement collapsed in: a flat 3.0 seconds across the whole set.
One smaller thing that cost me an hour and will cost me another one day if I do not write it down. The first photograph never fires a load event. It comes from the server-rendered HTML, so the browser can finish fetching it before the page has hydrated and attached the handler. The flag that was supposed to start the warming stayed false for the entire session, and the only symptom was that nothing happened. Reading the image’s complete property once on mount is the fix, and it is the same bug waiting in any component that arms behaviour off the load event of something the server rendered.
Live on the site.