Page Switching and First Paint
Prefetch and view transitions for page switching, plus CLS fixes and content-visibility long-article rendering
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:
- Prefetch matches by link selector: the strategy only applies to links carrying the
data-astro-prefetchattribute, or to every link when site-wideprefetchAll: trueis on. Not one template link on this site carried that attribute, andprefetchAllsat at its default of false. The match condition never held; - 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:
astro:before-preparation: navigation begins, the new pageâs HTML starts fetching. The skeleton clocks in here;astro:after-preparation: the HTML is ready and parsing begins;astro:before-swap: just before the old and new DOMs swap. The skeleton clocks out here and the animation takes over the screen;astro:after-swap: the swap is done and the new page is on screen. Logic that depends ondocumentglobal 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;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:
- 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; - Centering is left to the containerâs translate positioning; JS stacks only one scale variable on top;
- 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; - 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:
preloadthe 12KB font subset outright, squeezing the âfallback â real faceâ window to near-invisible. Remembercrossoriginon a preloaded font, or the browser refetches it in anonymous mode and the preload was wasted;- 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;
- There is a more modern tool as well: the
size-adjust/ascent-overridedescriptors 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;}- 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;
- The
autokeyword 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
autokeyword 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 printwhen 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, noplus-lighter; switch the whole thing off underprefers-reduced-motion; - persist only for elements resident across pages (player, widgets, the skeleton), never for content elements;
view-transition-nameglobally 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: autowithautoincontain-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.