Back to Colophon
Back to Colophon

The site felt chonky. Traced it from view transition jank on mobile through modal lag and same-page nav bugs to the real culprit: the critical CSS integration was async-loading stylesheets, causing massive layout shift on every page load.

#performance#cls#css#view-transitions#debugging

It started with mobile jank#

Navigation between pages on mobile felt off. The view transition animations, smooth cross-fades that looked fine on desktop, were sluggish on phones. The 300ms cross-fade was too slow for mobile’s expectations. Rather than tuning durations, I disabled the visual transition entirely on mobile. Astro’s <ClientRouter /> still handles the SPA-like DOM swap (no full page reload, scroll restoration, prefetching), but the animation itself gets skipped. Instant page changes, no jank.

Then modals got weird#

Opening a modal on mobile had visible lag. The modal itself was fast, but <ClientRouter /> was intercepting the hash navigation (#inquiry, #schedule) and running view transition logic before the modal could open. On desktop this was imperceptible. On mobile, where we’d just disabled the transition animation, the overhead was pure delay with no visual purpose.

The fix was to detect same-page hash navigations in a astro:before-preparation listener and set e.navigationType = 'push' plus e.swap() to skip the transition machinery entirely. Modal opens are now instant on mobile.

Same-page navigation broke the nav#

After fixing modals, I noticed that any same-page navigation (anchor links, hash changes) was breaking the nav layout. The view transition’s DOM swap was replacing the page content but leaving the nav in a half-initialized state. Web components lost their event listeners, layout classes got dropped.

The root cause was Astro’s swap() function running a full DOM diff even when navigating to the same page. The fix intercepts astro:before-swap for same-page navigations and replaces the swap with a no-op, just scroll to the target element. No DOM replacement means no broken components.

The real problem was CLS all along#

With the view transition issues fixed, the site still felt off. There was visible content shifting on first paint, even on desktop. Time to measure.

I set up Puppeteer with a PerformanceObserver listening for layout-shift entries. The numbers were bad:

Desktop Home:   CLS = 0.7847
Desktop About:  CLS = 0.7870
Mobile Home:    CLS = 0.0000
Mobile About:   CLS = 0.0000

A CLS of 0.78 is catastrophic. Google’s threshold for “poor” is 0.25. The nav bar’s action buttons were oscillating between two widths (250px and 298px, a 48px swing) as CSS loaded and re-applied.

Hunting the cause#

First suspect was font loading. The site uses font-display: swap with Fontaine-generated fallback metrics. Variable fonts trigger document.fonts loadingdone events repeatedly (13 times in my testing). Each event could cause text to reflow as the browser switches from fallback to real font.

I blocked all fonts in Puppeteer. CLS stayed at 0.78. Not fonts.

Next I disabled the critical CSS integration entirely, rebuilding without it. CLS dropped to 0.0000 across every page. That was the answer.

How async CSS loading causes layout shift#

The critical CSS integration I built was doing two things: inlining above-the-fold CSS for fast first paint, and converting all <link rel="stylesheet"> tags to async loading:

html
<style>/* critical CSS inlined */</style>
<link href="styles.css" rel="stylesheet" media="print" onload="this.media='all'">
<noscript><link href="styles.css" rel="stylesheet"></noscript>

The media="print" trick is a well-known pattern for non-blocking CSS. The browser downloads the file without blocking render, then the onload handler switches it to media="all" so the styles apply.

The problem: if the critical CSS extraction misses any rules needed for first paint, the page renders with incomplete styles, then shifts when the async stylesheet loads and the missing rules kick in. The extraction runs Playwright at 1920x1080, but real users view at different viewport sizes. Rules critical at 1440x900 might not be captured at 1920x1080 because different elements are in the viewport.

For the homepage, clearing the stale extraction cache mostly fixed it (CLS dropped from 0.78 to 0.008). But the about page remained at 0.78 even with a fresh cache, proving the extraction genuinely misses rules for some pages at some viewports.

The fix: sync with pruning#

The async loading was never worth the CLS risk. The real performance win from the integration was always the pruning, not the async loading. Each page gets a pruned copy of the external CSS with already-inlined rules removed. That’s a genuine file size reduction (8-15% per page) that benefits every visitor.

The fix was simple: keep the critical CSS inlined, keep pointing links at pruned copies, but stop making them async. Remove the media="print" onload trick, remove the <noscript> fallbacks. Let the browser load the (smaller) stylesheets synchronously, the way it always has.

html
<style>/* critical CSS inlined */</style>
<link href="styles.p-index.css" rel="stylesheet">

After the change:

Desktop Home:   CLS = 0.0000
Desktop About:  CLS = 0.0000
Mobile Home:    CLS = 0.0000
Mobile About:   CLS = 0.0000

What I learned#

The media="print" async CSS pattern is fine when your critical CSS extraction is perfect. It’s a disaster when it’s not. And viewport-aware extraction can never be perfect across all screen sizes unless you run Playwright at every breakpoint, which defeats the purpose.

Sync-with-pruning gives you most of the performance benefit (smaller files, critical CSS for fast first paint) with none of the CLS risk. The browser’s rendering pipeline already handles render-blocking CSS efficiently. Fighting it with async tricks adds complexity and fragility for marginal gains.

Four commits, one afternoon:

  1. Disable view transition animations on mobile
  2. Skip view transition overhead for modal navigation
  3. Fix same-page navigation breaking nav layout
  4. Fix CLS by switching critical CSS from async to sync-with-pruning
The site felt chonky. Traced it from view transition jank on mobile through modal lag and same-page nav bugs to the real culprit: the critical CSS integration was async-loading stylesheets, causing massive layout shift on every page load.