The stays page was scoring in the 70s with a TBT of 3,336ms — on a page that, by my own code comments, should never have downloaded a single video byte.
The comment in VideoThumb.astro said it plainly: “Lighthouse never scrolls so video bytes are never fetched.” That sentence was wrong, and it had been wrong for a while. Tracking down why it was wrong is the whole story here.
Why these four pages matter at all
This is one node in a larger system. The B&B’s direct-booking channel depends on Google Ads sending paid traffic to four landing pages. Every one of those visitors arrives on a throttled mobile connection, and Google’s Quality Score — which sets what the client pays per click — reads landing page speed directly. A slow page isn’t just a UX problem; it’s a tax on every click in the campaign, paid forever.
The client’s quote promised Lighthouse mobile ≥ 90. So the requirement was concrete: four pages, mobile, ≥ 90, LCP under 2.5s, TBT under 200ms. The four were the home page, the intro page, the access page, and the individual stay page — the deepest of the funnel and the slowest.
The deferral that defers nothing
The original gtag.js loader did the textbook thing: stub gtag synchronously so click handlers keep queuing into dataLayer, then load the real script via requestIdleCallback so it fires after TTI and never inflates TBT. In a real browser, that’s correct. The idle callback waits until the main thread has nothing better to do.
Lighthouse’s simulated throttling does not behave like a real browser. The simulator goes “idle” almost immediately after the initial parse — there’s no genuine interaction, no real CPU contention to keep the main thread busy. So requestIdleCallback fired right after parse, dragging the real gtag.js onto the network during the exact window the hero image needed.
The numbers: the Google Ads tag is 140KB, the GA4 sub-script another 177KB — 317KB total. On simulated slow-4G that bandwidth competes directly with the hero AVIF, pushing LCP from roughly 1s to about 3s. The idle-callback strategy didn’t reduce the cost; it relocated it onto the worst possible part of the timeline.
The fix was to stop trusting idle as a signal and tie loading to an event the simulator respects: window.load plus a 1,500ms delay. That guarantees the LCP paint completes before any of the 317KB starts downloading. The synchronous stub and the dataLayer replay stay exactly as they were, so no conversion event is ever lost — the queue just drains a little later.
IntersectionObserver has the same disease
The video thumbnails were the bigger offender, and the root cause was the same illusion. The original code used an IntersectionObserver with rootMargin: '200px 0px' to autoplay muted video when it scrolled near the viewport. The reasoning behind the comment: Lighthouse never scrolls, so the observer never fires, so the video never downloads.
But Lighthouse’s mobile viewport is 812px tall, and a 200px root margin extends the trigger zone far enough that the video thumbnails were already inside it at load — no scroll required. The observer fired on the first frame. That kicked off a preload of two video files totalling roughly 17MB on simulated slow-4G. That single download was the entire 3,336ms of TBT.
The surprise here isn’t that lazy-loading can misfire. It’s that two completely different lazy primitives — one idle-based, one viewport-based — both fail in the same direction under the same simulator, for the same underlying reason: the simulator’s model of “idle” and “out of viewport” doesn’t match a real session. A competent engineer reading the original comments would have nodded along; both rationales sound right.
I replaced the observer with plain click-to-play. No bytes are fetched until the visitor taps the poster. As a bonus, the click is itself a user gesture, so video.play() is allowed even unmuted on the first interaction — which the autoplay path could never get cleanly, since browsers reject unmuted autoplay on load.
The two changes that were actually about LCP, not TBT
With the bandwidth thieves removed, two smaller changes finished the job.
The stay templates got a heroPreload hint that emits a <link rel="preload" as="image" type="image/avif"> at HTML parse time, so the preload scanner starts fetching the LCP image before the layout engine discovers it. The stem is derived by stripping the file extension off the hero image path, so it stays in sync with whatever image the content collection points at.
The access pages had a Google Maps iframe loading its JavaScript at page start. I swapped it for a click-to-load facade — a styled placeholder with a pin icon that swaps in the real iframe only when tapped. Most visitors checking a B&B’s location want the address, not an interactive map; the ones who want the map get it on demand. Google Maps JS is now entirely off the critical path.
Where it landed
| Page | Score | LCP | TBT |
|---|---|---|---|
| stay detail | 97 | 1.9s | 180ms |
| home | 98 | 1.6s | 130ms |
| intro | 100 | 1.1s | 60ms |
| access | 100 | 1.2s | 90ms |
All four clear ≥ 90, LCP under 2.5s, TBT under 200ms. The honest caveat: these are local runs with no real network throttling and no real CPU throttle, so they read slightly optimistic against PageSpeed Insights. The stay page’s 180ms TBT sits just under the 200ms line and could tip over on PSI’s real conditions — worth watching after deploy rather than declaring done.
The thing I’ll carry forward: a deferral strategy is only as good as the runtime’s definition of “later,” and Lighthouse’s definition of later is sometimes now.