Skip to main content
This page was machine-translated and may contain errors or omissions.

Page Switching and First Paint

Prefetch and view transitions for page switching, plus CLS fixes and content-visibility long-article rendering

Fri Sep 25 2026
5715 words · 30 minutes

Page Switching and First Paint

This post is the merged record of the site’s performance rework: half on page switching, half on first paint.

Page switching has two problems to solve. Fast: the wait between the click and the new page appearing — prefetching and preloading can take out most of it. Smooth: how the swap from old frame to new frame looks — the default implementation has an obvious blank dip, and only view transitions remove it. First paint governs the page’s content: a static site’s HTML arrives fast, and what stalls users is usually the browser turning the DOM into pixels — unstable layout, where content jumps in front of your eyes (CLS); or render cost, where layout and paint over thousands of nodes leave the page blank for a second (long articles are the disaster zone).

The two parts meet at one point: the skeleton screen is both the floor for how a slow navigation feels and the placeholder insurance for dynamic content.

Faster Switching: Eliminating the Wait

Diagnosis: Why Prefetch Never Fired Once

“It feels fast” and “I’ve confirmed it’s as fast as it can get” are two different things.

Tracing this blog’s loading chain turned up an awkward fact: site prefetch had a default strategy configured, but in practice it was completely idling.

Hover, press, scroll into view — not one prefetch request ever went out.

The symptom: hovering internal links in the Performance/Network panel produced no “load ahead” request at all.

Reading Astro’s prefetch implementation end to end, I found that prefetch takes effect only when both of two conditions hold, and this site met neither:

  1. Prefetch matches by link selector: the strategy only applies to links carrying the data-astro-prefetch attribute, or to every link when site-wide prefetchAll: true is on. Not one template link on this site carried that attribute, and prefetchAll sat at its default of false. The match condition never held;
  2. Only defaultStrategy (the trigger: hover/tap/viewport) was set, with no switch turned on for “which links participate”. However well the strategy is tuned, with no candidate links it just spins in place.

The fix is two lines of config:

prefetch: {
prefetchAll: true, // every internal <a> takes part in prefetching
defaultStrategy: "hover", // start after 80ms of hover
},

Why hover rather than viewport: on desktop there are a few hundred milliseconds of natural slack between “reading → drifting toward the link → clicking”, and starting the prefetch after an 80ms hover fills exactly that window — by the time the click lands, the next page is usually already in the cache.

Touchscreens have no real hover; Astro synthesizes mouseenter from the tap, which amounts to “prefetch only after the click” — no worse than leaving it unconfigured.

On weak networks and in data-saver mode (saveData) the browser skips it internally, so it never burns the user’s traffic for nothing.

There are four trigger tiers in all, in increasing aggressiveness: hover (fetch after roughly 80ms of hovering, betting on an intentional click). tap (fetch on press, hiding the network time in the gap between press and release — starts earlier than hover on mobile). viewport (fetch the moment the link scrolls into view, watched with IntersectionObserver; the most aggressive and the most wasteful — one scroll down a list page fires a string of requests). load (once the page goes idle, fetch every internal link, effectively warming the whole site).

This site picked hover as the balance between hit rate and waste: a list page shows a dozen-plus links per screen, and viewport would spend ninety percent of its prefetches on links that never get clicked.

How to verify: tick “Disable cache” in the Network panel and reopen it, then hover any internal link — a request for the target page’s HTML should appear at once.

From zero requests before the fix to a request on every hover after — only then is the prefetch chain actually connected.

The Division of Labor Among Three Kinds of “Loading Ahead”

Along the way I sorted out three words that get mixed up constantly; they answer three different questions:

prefetch: “the next page will probably be visited, so take it early.”

What it acts on is the next page’s HTML (or resources), betting on user behavior.

Prefetch requests are low priority and do not compete with rendering the current page.

Fits: internal navigation links, pagination, search result pages.

preload: “this page needs it right now, don’t wait until the parser gets there.”

What it acts on is the current page’s critical resources, moving discovery up from “the CSS parser reaches that line” to “the very start of head”.

Typical examples: a first-screen hero image <link rel="preload" as="image" fetchpriority="high">, critical fonts.

Preload the wrong target (a resource that never actually gets used) and the console warns about it — plus the bandwidth is wasted.

modulepreload: “this page will later execute this JS module and its dependency graph.”

Specific to ES modules: it not only fetches the file, it parses and prefetches the module’s import tree as well.

That is the essential difference from an ordinary preload.

modulepreload Fills the Pre-Hydration Gap

Static site frameworks share a not-very-visible gap: the HTML has arrived and rendered, but the interactive components (islands) only hydrate once their JS modules finish downloading.

This site’s hydrator script declares component URLs near the bottom of the HTML, so by the time the browser notices “oh, there’s a module to download here”, the network may already have idled for a stretch.

Pointlessly stretching time to interactive.

The fix is a small script in the page: sweep the DOM for every island node and inject each declared component URL and renderer URL as a <link rel="modulepreload">.

Island module downloads then move up from “discovered when the hydrator runs” to “started the moment the parser reaches head”.

In the built output the modulepreload count went from 0 to one per island, and first-screen islands’ hydration wait no longer contains an extra network round trip.

A Counterexample

Adding <link rel="preload" as="font"> for the code font runs into a classic trap: font requests go out in anonymous (CORS) mode by default, so the preload must also carry crossorigin.

Only when both sides match does it hit the same cache entry.

Miss it and the browser fires two requests. The preloaded copy lies there unused, and the real use pulls it once more — the preload did nothing at all.

The console reports no error; only two font requests in the Network panel expose it.

What Should Not Be Prefetched

prefetchAll: true is a double-edged sword: it turns every internal link into a prefetch candidate, including the ones that should never get an early GET.

Prefetch is at bottom one GET against a URL, and “GET is always safe” is an assumption widely taken for granted and occasionally violated:

  • GETs with side effects: “log out” links, counter/check-in links, “mark as read” links. One prefetch and the side effect fires early — the user hasn’t clicked and the state has already changed. Turn these into POST buttons, or exclude them explicitly (in Astro, tag the link with data-astro-prefetch="false");
  • One-shot URLs: prefetching a link that carries a verification token or nonce consumes a one-time resource, so the real click fails instead;
  • Large, rarely visited targets: resource pages tens of MB in size, deep links almost nobody clicks. Prefetching them is buying insurance against a long shot with real traffic. Weigh the site’s total internal-link count before turning on the load strategy (site-wide warm-up).

The criterion is a single line: a prefetchable URL must be GET-idempotent, consume nothing one-time, and be worth its size.

Fail any one of the three and exclude it explicitly.

prefetchAll plus an exclusion list is far less trouble than opting links in one by one: safe by default, exceptions explicit.

Measuring the Effect

To verify the optimization took, the right move in the Network panel is to tick Preserve log (prefetching happens before navigation, and without preserved logs the entries get cleared) and then walk “hover → click” once more.

The prefetch request shows up with a prefetch-related label before the click; the navigation request after the click, on a cache hit, shows a very short duration or a memory/disk cache source.

The more quantitative route is to compare two Navigation Timing runs: with prefetch active, the next page’s navigation wait (the TTFB segment) should shrink noticeably.

Because the HTML is already in the cache, navigation no longer waits on the network.

Note that prefetch improves the next page’s navigation speed and does nothing for this page’s LCP — don’t go hunting for gains in the wrong metric.

Prefetch and the Cache Pool

Once prefetched resources land in the HTTP cache, the next click is not the only beneficiary.

A repeat visit to the same URL (going back to the list page, back/forward) hits directly, no request sent.

Read the waste ledger from the other side, too: the prefetch hit rate is always below 1, and the share that never gets clicked is a net expense.

So choosing a strategy is really pricing “hit rate × speed-up benefit” against “wasted traffic” — that is the axis on which the four tiers actually compare.

If a Service Worker also manages caching on the site, prefetch and it should be designed as a pair — see the Service Worker part of the caching and search post.

Smoother Switching: Fixing the Blank Dip

Once the speed problem is solved, the remaining blemishes stand out more, not less.

Astro’s <ClientRouter /> turns navigation into a browser-managed page swap. The URL still changes, the HTML is still a full page, but it feels close to a single-page app.

Yet “wire it up and it’s smooth” is an illusion: the default animation has an obvious blank dip.

The old frame fades out and the new one fades in; mid-way both layers are half-transparent, so what the user sees is a momentary “content wiped”.

First, See Why the Default Animation Looks Bad

Astro defaults to the browser-native ::view-transition-old(root) and ::view-transition-new(root) cross-fade.

The native keyframes are two layers at partial opacity running in parallel: mid-way the old page at 50% sits stacked on the new page at 50%.

The background bleeds through and the body text shows through both ways — visually, “the content blinks out for a frame”.

The heavier the page (new HTML still on the wire), the longer the dip.

The fix splits in two: cover the loading-time blank with a skeleton screen, and fix the swap-time dip with an animation curve.

Skeleton Screen

A skeleton screen is a placeholder animation covering the main content area (gray shimmer blocks); the point is not to look nice but to express “loading” as “a placeholder is waiting” rather than “the content is gone”.

Three implementation details decide how it feels:

One, cover only the region that changes.

The skeleton is absolutely positioned inside the main content column; the navbar and sidebar — elements that stay identical across pages — stay outside the mask.

They are already reused across pages via transition:persist, and covering them only manufactures the illusion of a full-page reload.

Region-scoped placeholders are what fundamentally separates a skeleton screen from a full-screen loading state.

Two, show it after an 80ms delay.

A navigation that hits the prefetch (the config from earlier) may take only tens of milliseconds from start to ready. If even that “instant swap” flashed a skeleton, every page would be announcing “I am slow”.

Delay the skeleton by 80ms: a prefetch-hit navigation completes outright and the skeleton never appears. Only genuinely slow loads (unprefetched, weak network, the back button) put it on duty.

Three, the placeholder mirrors the real layout.

The blocks are drawn in the shape of the real page’s cards (hero band + title line + text lines), so when loading completes the new content “connects” to the placeholder instead of jumping.

That also insures against CLS (stable placeholder dimensions).

On the timeline it hangs off Astro’s navigation events: shown at astro:before-preparation (the new page starts fetching), hidden at astro:before-swap (the new DOM is ready).

Transition Animation

The dip’s root cause is two layers fading out and in, in parallel.

In that case, simply don’t let the old layer move:

::view-transition-old(root) {
animation: none; /* the old frame holds in place */
}
::view-transition-new(root) {
animation: frosti-vt-fade-in 240ms ease-in both; /* the new frame eases in from above and covers it */
}

The old frame stays fully opaque until the swap ends; the new frame eases in over 240ms and covers it.

At every instant the screen holds complete content — either the old one, or the new one coming up over it.

Note that the custom keyframes deliberately avoid plus-lighter blending (that is the birthplace of the default cross-fade).

Perceptually this approach has a side benefit: with the old layer static, the page switch has a stable “visual anchor”, and the eye doesn’t jitter along with the content.

Persistence

Beyond the skeleton screen, what really decides “does this feel like an SPA” is cross-page elements.

transition:persist keeps a designated element’s DOM from being rebuilt on navigation.

This site marks the skeleton itself, the Live2D widget, and the music player as persist: through a swap they stay put, the music keeps playing, and the mascot doesn’t flicker.

Use the flag with restraint, and the criterion is a single line: it only makes sense for an element that “exists on every page with continuous state”.

Putting persist on ordinary content only produces the eerie picture of new and old content swapping places.

Scoped Transitions

::view-transition-old(root) governs the whole-page snapshot, but transition groups are not limited to one layer.

Give an element a view-transition-name and it detaches from the whole-page snapshot, becoming its own pair of old/new snapshots that the browser transitions separately.

This is exactly the machinery of the “shared element transition”: give the list page’s card and the detail page’s hero the same name, and on navigation the browser captures the position and size delta between the two spots and generates a translate-and-scale animation on its own.

That effect of an article card “flying” into the detail page’s hero, done with one line of CSS naming and no JS at all.

The discipline for using it is like persist’s: names must be globally unique, at most one pair per page.

Two elements fighting over the same view-transition-name and the transition aborts with an error (the whole-page animation dies); fortunately the error message is clear.

To enable cross-document transitions in an MPA (so plain link jumps animate too), add one more CSS declaration, @view-transition { navigation: auto; }, and require both sides to be same-origin.

It makes every technique in this post (skeleton, frozen old layer) apply to pure MPA navigation as well.

The Life of the Navigation Events

The two events the skeleton hooks are only two stations on the ClientRouter lifecycle.

The full sequence is worth listing, because every site-level widget has to pick its own station to get off at:

  1. astro:before-preparation: navigation begins, the new page’s HTML starts fetching. The skeleton clocks in here;
  2. astro:after-preparation: the HTML is ready and parsing begins;
  3. astro:before-swap: just before the old and new DOMs swap. The skeleton clocks out here and the animation takes over the screen;
  4. astro:after-swap: the swap is done and the new page is on screen. Logic that depends on document global state re-syncs here. This site’s i18n language binding hangs here: module-level singletons don’t re-run on navigation, so once <html lang> has changed it must be read once by hand;
  5. astro:page-load: everything is ready (it also fires once on the first load). Logic that must “run on every new page” goes here — ad slots, for instance: after a swap, the new <ins> element has to be pushed again before it requests a fill.

Hooking logic onto the wrong station has a textbook set of symptoms: too early and the new DOM isn’t there yet; too late and one frame of stale state flashes; at module top level and it runs only on the first load.

Two navigations later it has “mysteriously stopped working”.

Respecting the Reduced Motion Preference

Transition animations are meant for people who haven’t objected.

When the system turns on reduced motion (prefers-reduced-motion, a common setting for users with motion sickness and attention disorders), large translate-and-scale animations are a direct physiological burden and should be switched off wholesale:

@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}

With it off, navigation degrades to an instant swap — precisely the origin of the “default animation looks bad” problem — but for users who chose reduced motion, stable and predictable matters far more than “smooth”.

The skeleton screen is unaffected (it expresses state, it is not decorative animation) and stays on duty.

Browser Support and Fallback

View transitions are a fairly recent browser capability, but the fallback path comes free: in older browsers without ::view-transition, the ClientRouter automatically reverts to an ordinary MPA jump.

No loss of function, the URL still changes, only the animation is missing.

So the risk floor of adopting it is “nothing happens” — worth wiring in first and tuning after.

CLS Fixes

CLS (Cumulative Layout Shift) is the Core Web Vitals metric that bullies you the most.

Slow LCP at least shows up in the timing; a CLS problem only ever shows up as “content jumping in front of my eyes”.

Settle on one method first: record a page load in the DevTools Performance panel, open the purple Layout Shift regions in the Experience row to see the moved elements and where the score came from; or run Lighthouse for a score to watch for regressions.

Understanding the scoring formula

Before optimizing it pays to know how the browser scores a shift, otherwise you can’t tell which change will help.

Each shift’s score = impact fraction × distance fraction.

The first is “how much of the viewport moved” (the unstable element plus everything it shoved, as a fraction of viewport area); the second is “how far it moved” (displacement divided by the viewport’s largest dimension).

Total CLS is not a running sum but the maximum over session windows.

A run of shifts no more than 1 second apart counts as one window, a window lasts at most 5 seconds, and the page takes the highest of all windows.

There is one exemption: shifts within 500 ms of user input don’t count (content changing after a button press is expected behavior).

These details decide the priority order: kill the occasional “large area × long distance” shift first, while a string of small jitters is secondary.

Fix one: scaling the hero headline

The hero images on the home page and the channel pages carry a full-width headline (HOME, GAMES and the like), required to fit the container width and stay on one line without truncating.

The first implementation measured the text width and then changed font-size to fit.

The trouble is that font-size is a layout property: once the font loads the text must be measured again and rescaled, and measured in the wild the word “HOME” jumped from 51px to 584px wide, two reflows around it knocking the whole page up and down.

Measured, it turned out to be the single largest CLS source on the site.

The fix replaces the reflow-triggering property and keeps only compositor properties:

  1. Base font size fixed at 100px, with adaptation done purely by transform: scale(). transform doesn’t trigger layout, doesn’t count toward CLS, and doesn’t reflow anything around it;
  2. Centering is left to the container’s translate positioning; JS stacks only one scale variable on top;
  3. Until adaptation finishes the element is visibility: hidden. The space is still taken and the size is stable, which avoids the single frame of “unscaled giant text” flashing up;
  4. The scale ratio is capped (about 1.92×) so short text doesn’t balloon absurdly on wide screens.

After the fix this item’s shift went to zero.

Rule one: any “scale content to the container” requirement uses transform, never font-size/width/height.

Why a transform’s movement doesn’t count toward CLS is worth one more sentence: CLS measures changes in layout box position.

How much document space an element occupies and which coordinate it starts from.

transform is the visual transform at the very last stage of rendering; the layout box hasn’t moved a hair, so the browser doesn’t consider the layout unstable.

That also draws the boundary of what works: transform suits moving and scaling visuals, but once the thing that “looks moved” still has to participate in document flow (a collapsible panel shoving the content below it down, say), the layout really did change and CLS counts it as usual.

What that case calls for is reserving space for the expanded size.

Fix two: ad slots and asynchronously injected content

After AdSense went in, new shifts appeared in the performance panel: the ad script arrives late, and when the ins element is injected or filled, everything below gets pushed down.

Ads are third-party and asynchronous, their arrival timing entirely out of your control, so the only reliable fix is reserving a constant slot for them:

  • the ad slot component renders a placeholder container with a fixed min-height from the start, so the outer size is unchanged before and after the ad fills;
  • the content column draws its skeleton placeholders at real card dimensions too (rounded card + hero band + text lines), so on load the new content “covers” the placeholder instead of “shoving it aside”.

Rule two: anything whose size your first-paint HTML doesn’t determine — ads, embedded iframes, lazy-loaded images, asynchronously fetched recommendation lists — must have reserved dimensions.

Images get width/height attributes plus CSS aspect-ratio; ads and iframes get a fixed min-height. No exceptions.

Fix three: the glyph jump after the font arrives

Code blocks use a self-hosted monospace font; under font-display: swap the fallback renders first and is replaced once the font arrives.

The difference in glyph widths makes code block content jitter horizontally once.

swap exists so first paint doesn’t wait on the font; the jump needs a separate remedy:

  1. preload the 12KB font subset outright, squeezing the “fallback → real face” window to near-invisible. Remember crossorigin on a preloaded font, or the browser refetches it in anonymous mode and the preload was wasted;
  2. Subsetting serves CLS as well: CJK fonts run to several MB, and the bigger the font the longer the “fallback → real face” window and the more visible the jump. The smaller the subset, the narrower the window;
  3. There is a more modern tool as well: the size-adjust / ascent-override descriptors of @font-face, which calibrate the fallback’s metrics toward the real face. Even when the swap still happens, the amplitude of the jump itself is smaller.

Rule three: preload critical fonts and subset as far as possible, so the “font swap” happens before the user can see the page clearly.

Fix four: placement sense for dynamically inserted content

The “dynamically inserted content” item on the checklist deserves its own section, because it is the main force behind returning-visitor CLS.

The page has settled, and then a banner, a toast, a “you may also like” drops out of the sky and pushes half-read prose down.

Placement decides everything.

Appending below the viewport (the next page of infinite scroll) is safe. There is nothing below it to push.

Inserting above or in the middle of the viewport inevitably displaces existing content.

The only cost-free top insertion is an overlay: a position: fixed element leaves document flow and covers content instead of displacing it.

So toasts, cookie bars and other temporary notices have their right shape as overlays or “off-viewport placeholders”, and their wrong shape as something inserted at the top of document flow.

Content that can’t leave flow yet must go on top (a paywall notice, say) falls back to rule two: reserve it constant space, so the outer size is identical before and after it arrives.

Field and lab

What Lighthouse produces is a lab score: simulated device, simulated network, one load.

Real users’ CLS speaks through field data.

Chrome reports real-user Core Web Vitals into the CrUX dataset, which is what Search Console and PageSpeed Insights display, scored at the 75th percentile (p75) of each metric’s field distribution.

The two sets of numbers often disagree: lab all clear, field ugly — usually the gap between real devices (low-end Android) and the simulated environment (throttled desktop).

When optimizing, let the field score call the outcome and the lab panel find the culprit.

The former tells you “how bad, and for whom”; only the latter can replay frame by frame.

To see the raw data on your own site, a few dozen lines of script suffice. The browser exposes every shift as an entry:

new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue; // shifts within 500ms of user input; the browser doesn't count them anyway
console.log("shift:", e.value, e.sources?.map((s) => s.node));
}
}).observe({ type: "layout-shift", buffered: true });

What you get is each shift’s score and the offending node (sources), which suits long-term collection better than the Performance panel.

Run it inside your analytics for a few days and it becomes obvious which page types and which components manufacture shifts for real users.

The full score still lacks the “session window aggregation” step; add it when you build the monitoring.

content-visibility: skipping off-screen rendering

Layout is stable now, but the other half of the blank-screen-on-navigation problem remains: this blog has quite a few ten-thousand-character articles.

Deep learning notes carry hundreds of KaTeX formulas, tutorial posts dozens of code blocks.

Switch to a page like that and the HTML arrived long ago (static site, transfer isn’t the bottleneck); what stalls is the browser turning thousands of DOM nodes into pixels.

Measured on a math-heavy page with 359 formulas, browser rendering after the page switch took about 1.6 seconds, and the page was blank the whole time.

content-visibility: auto is a CSS property built for exactly this case: it lets the browser skip rendering content outside the viewport.

The payoff is immediate, but on long articles with table-of-contents anchors it buries one inconspicuous trap.

The approach: skip off-screen rendering block by block

#content > * {
content-visibility: auto;
contain-intrinsic-size: auto 480px;
}

The principle in one sentence: for an element with content-visibility: auto that isn’t near the viewport, the browser skips its layout/paint (the content still exists in the DOM and the accessibility tree, so Ctrl+F and anchor navigation still work) and it participates in layout only at the placeholder size given by contain-intrinsic-size.

It renders for real only as scrolling approaches.

Applied only to the article’s direct children (paragraphs, headings, code blocks, figures), the granularity is just right: one paragraph off screen saves one paragraph’s rendering, and there’s no need to go finer.

Support is no longer an issue: available since Chromium 85, with Firefox 125 and Safari 18 following in 2024, all three engines are in place.

It is a textbook case of progressive enhancement. An old engine doesn’t know the property, long articles render in full as before, and you simply don’t save that rendering time.

Why the payoff is so large

The first screen is only a small part of a long article.

In a 30,000-character article the content inside the viewport might be 5%, and the other 95% of paragraphs, formulas and code blocks all skip their first layout and paint.

KaTeX formulas are the worst offender. Each formula is a deeply nested DOM subtree, and on a page with 359 formulas that is the bulk of the 1.6 seconds.

And it is pure CSS: no JS measuring scroll position, and none of the flicker of lazy loading’s “load it once you scroll there”.

How to measure the payoff

Measure a baseline before applying the property, so you don’t end up with “it feels faster”.

Record an “open a long article” load in the DevTools Performance panel and look at two things: the total duration of Recalculate Style / Layout / Paint in the main-thread flame chart (page-switch jank hides right there), and whether there is a Long Task of a few hundred milliseconds.

Then temporarily disable the content-visibility rule in the Styles panel and record again.

The difference in render time between the two runs is the real payoff.

On the site’s 359-formula page, turning the property off puts render time back at the 1.6 second mark; with it on, only a fraction is left.

Note that the control run needs the same machine and the same cache state; two articles with different formula counts aren’t comparable.

Interaction with lazy-loaded images

content-visibility governs rendering, loading="lazy" governs the network, and having both in one long article is normal — know the rules of their interaction.

A lazy image triggers when it is “close to the viewport”, and the viewport position in turn depends on layout height.

Layout height is estimated from contain-intrinsic-size while off-screen blocks are skipped.

Hence a dependency chain: accurate placeholder height → accurate scrollbar position → accurate lazy-load timing.

When the placeholder is badly off, the symptom is images “arriving in batches late” or “yanked in early”.

The mitigation is the same source as trap two: give contain-intrinsic-size the auto keyword so scrolled-past blocks converge on their height, and always add width/height attributes to images (with known dimensions the browser lays out without waiting for the download).

Once both are in place, the interaction is essentially imperceptible.

Trap one: forgetting the auto keyword in contain-intrinsic-size

contain-intrinsic-size supplies placeholder dimensions for off-screen elements.

The version most people write first is:

contain-intrinsic-size: 480px; /* trap */

The consequence of hard-coding one value: every off-screen block is treated as 480px tall.

Real block heights are wildly uneven, though. An ordinary paragraph is 40–80px, a code block or a large table runs from several hundred to over a thousand.

So the page’s total scroll height is wrong, the scrollbar ratio is wrong, and everything downstream that depends on height goes wrong with it.

The correct form is auto 480px: the auto keyword makes the browser remember the real size the element rendered at last time, so once a block has rendered its real value participates in layout, and 480px is only the fallback on first encounter.

After one scroll through, placeholder heights have largely converged on the real ones.

Trap two: anchor jumps landing off target

This trap is far subtler than the first, and the symptom is odd: click a subsection in the table of contents and the jump lands most of a screen away, and the further down the heading, the more it misses by.

The reasoning: an anchor jump scrolls the browser to an element’s position.

A heading with content-visibility: auto that has never rendered participates in layout at its estimated placeholder height.

However much the estimate is off, that is how far the landing point is off.

Going down from the top of the page the error accumulates block by block, hence “the further down, the bigger the miss”.

The fix has two layers:

/* headings themselves don't take part in content visibility, so anchor targets can always be measured and located precisely */
#content > :is(h1, h2, h3, h4, h5, h6) {
content-visibility: visible;
}
  1. Exempt the headings: headings themselves cost almost nothing to render (no formulas, no code blocks), so let them render in full. That way the anchor target is always measured precisely and the landing point is always right;
  2. The auto keyword from trap one mitigates the same problem. Blocks that have been scrolled past converge on their real heights, so the error only shows up in the deep water you’ve never visited.

With both in place the anchor drift is gone and the payoff is unaffected (only headings are exempt, and they are a negligible share of render cost).

Cost and boundaries

content-visibility isn’t free:

  • The scrollbar jumps: the gap between placeholder and real height is corrected gradually while scrolling, so the thumb wobbles slightly. The auto keyword mitigates this a lot, but a long article on first visit still shows it;
  • Ctrl+F: modern browsers temporarily render matches, so the experience is basically intact, but very old engines behave inconsistently;
  • Printing: the viewport concept doesn’t hold when printing, so check how the print styles behave and disable it inside @media print when needed.

Its boundaries are clear too: long content that is list-like, streaming, and loosely coupled between blocks benefits most; where blocks are tightly coupled (heavy interdependent absolute positioning, say), verify block by block.

Two sibling values are easy to confuse, so to be clear: content-visibility: hidden means “don’t render at all”.

It skips rendering and removes the subtree from the accessibility tree and from find-in-page, which suits collapsible panels you know shouldn’t be shown for now.

auto keeps the accessibility tree and Ctrl+F reachability and merely doesn’t paint pixels.

Long articles must use auto; the symptom of mistaking it for hidden is “the page looks fine but full-text search can’t find the second half”.

Granularity matters too: per paragraph is too fine (too many elements, and maintaining skip state costs the browser something), per article is too coarse (there is always content in the viewport, so nothing gets skipped).

Direct children is the right level, which is why the site’s approach chose it.

The Page-Switch Checklist

  • First confirm the prefetch chain really works (hover and watch for requests) — “feels fast” is not the same as “the chain is connected”;
  • For Astro: prefetchAll: true + the hover strategy is a free speed-up for internal navigation, with automatic degradation on weak networks; prefetchable URLs must be idempotent, consume nothing one-time, and be worth their size;
  • prefetch serves the next page, preload serves the current page’s critical resources, modulepreload handles the module dependency graph. Pick the tool by the problem; font preloads must carry crossorigin;
  • <ClientRouter /> hands navigation over to Astro; URL and history are automatically correct;
  • Skeleton screen: cover the content area only, 80ms delay, mirror the real layout, hook the before-preparation / before-swap events;
  • Transition animation: old layer animation: none, new layer easing in over it, no plus-lighter; switch the whole thing off under prefers-reduced-motion;
  • persist only for elements resident across pages (player, widgets, the skeleton), never for content elements; view-transition-name globally unique;
  • Regression check: walk the main navigation on a weak network (Slow 4G) — the skeleton must appear gracefully, with no blank frames and no content jumps, before you can call it done.

With the whole set in place, MPA page transitions land at the balance point of “close to an SPA without an SPA’s complexity”: every page is still complete HTML, directly linkable, fully readable by crawlers, while what the user sees is a continuous picture.

The First-Paint Checklist

Both parts’ rules in one list, to run through before shipping:

  • images/video have dimension attributes or aspect-ratio;
  • ad slots and embedded frames have a constant placeholder;
  • dynamically inserted content goes outside the viewport, in an overlay, or with space reserved;
  • content scaling uses transform, not layout properties;
  • critical fonts are preloaded, subset, and (optionally) calibrated with size-adjust;
  • single-page navigation uses a fixed-size skeleton, not “content out of nothing”;
  • long articles enable content-visibility: auto with auto in contain-intrinsic-size;
  • anchor targets (headings) are exempted with content-visibility: visible;
  • the behavior of both properties is confirmed under @media print.

There is one more layer of navigation-related shift that’s easy to miss: the view transition itself. If a page-change animation leaves the old and new content containers at different sizes, the “jump” during the animation gets counted into CLS too — which is exactly what the earlier skeleton-screen and transition work has to take into account as well.

One last bit of experience: every new feature (ads, widgets, a font swap, a longer article) can introduce a new shift source or render hotspot. Make the Layout Shift and render-time checks in the Performance panel a fixed step before shipping; it is far cheaper than firefighting afterwards.


Thanks for reading! Follow me if you'd like~

Page Switching and First Paint

Fri Sep 25 2026
5715 words · 30 minutes