Our production site is deliberately boring technology: hand-written HTML, one compiled CSS file per page, no framework, no build step, no CDN, shared hosting. Boring, but not slow — an earlier pass that removed the Tailwind Play CDN and self-hosted our fonts took the homepage from 57 to 94 in the official mobile PageSpeed batch. What it didn't fix was layout shift. Mobile CLS medians across our 11 key pages sat between 0.028 and 0.061, with worst runs striking 0.351; on desktop, the homepage median was 0.485. Here's where the shift actually lived (not images), the two CSS fixes that killed it, and the three "obvious" fixes we measured, rejected, and reverted.
The shift wasn't images — it was icons
We diagnosed with Lighthouse's layout-shift attribution plus rect-timeline probes that sample element geometry over time. Every page told the same story: the header and topbar moved after first paint, at the moment the icon systems landed.
The site uses Lucide icons. In the HTML they're placeholder elements; a script later swaps each one for a real SVG. An inline placeholder has no intrinsic size, so it collapses to roughly zero until the swap. Measured effects: the mobile menu button grew 16×16 → 48×48 px (header +16 px), topbar text clusters widened ~43 px, the theme-toggle button grew 8×8 → 24×24 px. The Font Awesome brand icons in the social cluster had the mirror-image problem — no width until the FA webfont arrived, so the cluster widened 72 → 100 px mid-load. None of this shows up if you only audit images and ad slots, which is where most CLS advice (https://web.dev/articles/cls) quite reasonably points first.
The second, subtler shift
Sizing the placeholders removed most of it, but a residual survived — and it took the rect probes to see why. The placeholder sits on the text baseline, inheriting font-descent space below it. The swapped-in SVG computes vertical-align: middle instead. So the theme button rendered 28 px tall before the swap and 24 px after, and main jumped −4 px.
Four pixels sounds like nothing. But CLS weights the area that moves: on pages with a near-full-viewport decorative layer, that 4 px jump of a huge visible region scored ~0.19–0.21 CLS by itself.
The fix: reserve the icon's exact final box
Everything shipped as one appended style block. The core of it:
i[data-lucide]:not(.hidden):not(.md\:hidden){display:inline-block;vertical-align:middle}
i.fab.fa-linkedin-in,i.fab.fa-instagram,i.fab.fa-facebook-f{display:inline-block}
i.fab.fa-linkedin-in,i.fab.fa-instagram{width:.875em}
i.fab.fa-facebook-f{width:.625em}
Three things matter here. display:inline-block lets the placeholder take the width/height from its own size utility classes, so it reserves exactly the box the SVG will occupy. vertical-align:middle matches the SVG's computed alignment, killing the baseline-descent effect above. And the :not() guards are load-bearing: the stylesheet must never override the hidden state of the 111 sun/moon theme-toggle icons and responsive arrows whose visibility is conditional. The FA widths are the fonts' exact glyph advances, so the settled rendering is pixel-identical to before — this is a geometry reservation, not a design change. On the contact page, mobile CLS went from 0.061 in 3 of 3 baseline runs to 0.000.
The decorative layer that moved with the text
The second fix was a different mechanism entirely. Our hero glow was centred against its section:
.hero-glow{top:50%;transform:translate(-50%,-50%)}
.hero-glow{top:56px;transform:translate(-50%,0)}
@media (max-width:639px){.hero-glow{top:64px}}
When the webfont landed and the hero text re-wrapped, the section's height changed — and a top:50% element physically moved with it. We measured the settled glow position on each of the 19 affected pages (fonts loaded, animations frozen, desktop and mobile) and replaced the centring with that fixed anchor. The glow can now never move when the section does. Because this touched something visual, it was parity-gated: settled-state captures before and after showed computed styles and rects of 15 key elements per page identical (except the glow's own values, and 0.5 px rounding on some captures), with zero strong pixel diffs on 45 of 48 captures. The 3 exceptions were our JS typed headline caught at different typing phases.
Results
Mobile CLS after the pass: 0.000 — median and worst run — on all 11 pages. Desktop medians fell everywhere: homepage 0.485 → 0.002, websites 0.189 → 0.038, the long-form blog page 0.336 → 0.128. In the official PSI batch we ran after the pass, CLS read 0 or near-0 on 8 of 9 runs, and desktop LCP was ≤ 1.0 s on every desktop run.
LCP had no lever to pull
The LCP breakdown (https://web.dev/articles/lcp) on all 11 pages contained exactly two subparts: time to first byte, and element render delay. Resource load delay and duration were absent in every run — because the LCP element is hero text on every page (on one page, a decorative code-text layer). There is no image-LCP page in the set at all, so the standard playbook — preload the hero image, add fetchpriority — had nothing to attach to. What's left is TTFB (an origin property; our local rig reads 0.77–1.27 s through its proxy path versus 0.2–0.4 s of true origin response measured separately) and the intrinsic cost of painting text once its font lands. In the lab, desktop LCP was ≤ 1.21 s on all 11 pages both before and after — the composition simply isn't page-side fixable.
Three fixes we measured and reverted
- fetchpriority="high" on the font preloads. Home desktop CLS over 5 runs: median 0.466 with the change, 0.468 without. Zero effect on the font-race re-wrap. Reverted.
- CSS containment on the decorative code layer (contain: layout paint plus will-change). Render delay moved inside run-to-run noise. Reverted.
- Preloading that layer's mono font. No improvement beyond noise. Reverted.
(A critical-CSS pattern from an earlier phase had already failed its measurement gate and wasn't retried.)
The discipline behind those reverts: on a noisy shared origin, single-run deltas are fiction. We take medians of 3 runs per page per stage, 5-run checks on pages prone to strikes, and we distrust any batch whose conditions changed mid-flight — our final batch ran under visibly heavier machine contention, and its LCP deltas tracked the contention, not the code. Field data would arbitrate better, but CrUX still reports "No Data" for this origin, so lab medians plus official PSI batches are the evidence we have.
What's still broken, honestly
Two font-metric re-wraps remain, both attributed by Lighthouse to "Web font loaded": the homepage's desktop H1 (0.466 when it strikes; median 0.002 in the final batch) and one blog intro paragraph (0.128). The real cures — fixed text-block heights, different typefaces, or font-display behaviour that never swaps a late font in — are all design changes. So they're written down as decisions for the site's owner, not silent fixes. We shipped what was measurable and provably design-neutral, and documented the rest.
We build sites like this at RapidBizz (https://rapidbizz.com) — hand-built, measured in batches, and reverted when the numbers say so.
Top comments (0)