Sequencing Astro Client Directives for Faster Interactivity

The page is built on Astro’s islands architecture, yet LCP and TBT look like a single-page app’s — because every island was shipped with client:load, so all of their module graphs fetch and hydrate at once, in the same window as the hero image.

Root Cause: client:load Everywhere Recreates the Monolithic Bundle Problem

Astro’s promise is that JavaScript is opt-in per component, but the timing of that JavaScript is set per island by its client directive, and client:load is the maximal choice: as soon as the page loads, Astro’s hydration controller kicks off the dynamic import() for that island’s component module, its framework runtime (React, Vue, Svelte, Solid), and every dependency in between. Give five islands client:load and the browser opens the module graph of all five simultaneously. ES module fetches are dispatched at low-to-medium fetch priority relative to LCP-critical resources, but priority labels only order dispatch — once the responses are streaming, chunk bytes share the connection’s bandwidth with the hero image, and once they arrive, compile-and-hydrate work occupies the main thread that should be layout-and-painting the LCP frame.

The failure is invisible in component-level thinking because each island looks innocent: a 12 KB carousel, an 8 KB newsletter form, a 30 KB comment widget. The waterfall tells the real story — the shared framework runtime chunk (40–130 KB depending on the framework), plus per-island chunks, plus their common dependency chunks, all begin fetching within the first 300 ms. The aggregate regularly exceeds the LCP image’s own byte weight, meaning the page spends its most valuable bandwidth window downloading interactivity for components the user has not seen, will not scroll to, or cannot use yet.

The design error underneath is treating the directive as a boolean (“this island needs JavaScript”) rather than as a scheduling declaration (“this island needs JavaScript at this point in the session”). Astro exposes a full spectrum — client:load, client:idle, client:visible, client:media, and client:only — and each maps to a distinct fetch trigger. Sequencing islands across that spectrum is the islands-architecture equivalent of code-splitting a bundle, with the same payoff: the critical window carries only what the first paint and first interaction genuinely require. Deeper module-graph pathologies (chained dynamic imports inside an island) are a separate fix, covered by modulepreload and ES module loading.

Directive → Fetch-Timing Mapping

Directive Fetch trigger Typical fetch start Hydrates Use for
client:load Page load, immediately 0–300 ms ASAP after fetch The one island needed for first interaction (nav toggle, search box)
client:idle First requestIdleCallback After critical path clears At idle Above-the-fold islands that tolerate ~1 s of inertness
client:visible IntersectionObserver entry On scroll proximity When (nearly) in view Below-the-fold islands: comments, carousels, maps
client:media matchMedia match Only when the query matches On media match Viewport-conditional UI: mobile drawer, desktop-only widget
client:only Page load (no SSR of the island) 0–300 ms ASAP; blank until then Client-only components — use sparingly, it also skips server HTML

The table is a lookup, but the assignment itself is a short chain of yes/no questions asked once per island, and the branches are mutually exclusive: the first one that matches wins, and everything that falls through the chain is scroll-gated by default.

Which client directive does this island get? A four-question decision chain. Question one asks whether the island is needed for the first interaction within about a second; yes gives client:load, allowed on at most one island. Question two asks whether it is used at only one breakpoint; yes gives client:media. Question three asks whether it is above the fold and tolerant of about a second of inertness; yes gives client:idle. Question four asks whether it sits within one fast scroll gesture of the fold; yes gives client:visible with a 400 pixel rootMargin, and no gives plain scroll-gated client:visible. Which client directive does this island get? 1 — needed for the first interaction (nav toggle, site search) inside ~1s? client:load at most one island on the page yes no 2 — used at one breakpoint only? (mobile drawer, desktop-only widget) client:media excluded breakpoint never fetches yes no 3 — above the fold, but tolerates about a second of inertness? client:idle fetches at the first idle period yes no 4 — within one fast scroll gesture of the fold? client:visible, rootMargin 400px hydration starts 400 px early yes no client:visible strictly scroll-gated One island keeps the eager slot; every other answer is a scheduling declaration for a later moment in the session.

The Contention, Before and After Sequencing

Astro island module fetches vs the LCP image, before and after directive sequencing Top waterfall labeled all client:load shows the framework runtime and the island chunks downloading in parallel with the hero image between 0.2 and 2.6 seconds, pushing LCP to 2.9 seconds. Bottom waterfall labeled sequenced directives shows the hero image finishing at 1.48 seconds with LCP at 1.6 seconds, the nav island fetching early, the runtime and carousel fetching after the idle callback at 2.1 seconds, and the comments island fetching only when the user scrolls at 4 seconds. 0s 1s 2s 3s 4s 5s All client:load hero image LCP 2.9s runtime chunk nav island carousel island comments island 5 module graphs share the hero's bandwidth Sequenced directives hero image LCP 1.6s nav (client:load) runtime + carousel (idle) idle callback ≈ 2.1s comments (visible) scroll at 4s LCP image transfer eager island fetch deferred island fetch

Minimal Reproduction

A typical marketing page with a copied-and-pasted directive on every island:

---
// BROKEN: src/pages/index.astro — five islands, one directive.
// All five module graphs open at page load and race the hero image.
import Hero from '../components/Hero.astro';
import NavMenu from '../components/NavMenu.jsx';
import Carousel from '../components/Carousel.jsx';
import NewsletterForm from '../components/NewsletterForm.jsx';
import Comments from '../components/Comments.jsx';
import MobileDrawer from '../components/MobileDrawer.jsx';
---
<Hero />
<NavMenu client:load />
<Carousel client:load />
<NewsletterForm client:load />
<Comments client:load />
<MobileDrawer client:load />

Sequenced, each directive states when its island’s JavaScript is actually needed:

---
// FIXED: directives as scheduling declarations, not booleans.
import Hero from '../components/Hero.astro';
import NavMenu from '../components/NavMenu.jsx';
import Carousel from '../components/Carousel.jsx';
import NewsletterForm from '../components/NewsletterForm.jsx';
import Comments from '../components/Comments.jsx';
import MobileDrawer from '../components/MobileDrawer.jsx';
---
<Hero />
<!-- First interaction target: keep the eager slot, but ONLY here -->
<NavMenu client:load />
<!-- Above the fold, tolerates ~1s of inertness: wait for the idle period -->
<Carousel client:idle />
<!-- Mid-page: fetch when the user approaches, with a 400px head start -->
<NewsletterForm client:visible={{ rootMargin: '400px' }} />
<!-- Deep below the fold: strictly scroll-gated -->
<Comments client:visible />
<!-- Desktop users never pay for the mobile drawer's module graph -->
<MobileDrawer client:media="(max-width: 768px)" />

Deterministic Fix Protocol

  • [ ] 1. Inventory islands and their directives. Search the codebase for client: and tabulate component, directive, byte weight (from the build manifest or astro build output), and fold position. This table is the work plan for every following step.
  • [ ] 2. Establish the contention baseline. In the Chrome DevTools Network panel (JS filter), reload and note when each island chunk starts relative to the hero image, then confirm in a Performance trace that hydration tasks overlap the pre-LCP window. Screenshot the waterfall — it is the before-evidence for step 7.
  • [ ] 3. Grant client:load to at most one island. Whichever component the user plausibly interacts with in the first second — usually navigation or site search — keeps the eager slot. Everything else must justify itself against a later trigger.
  • [ ] 4. Assign client:idle to remaining above-the-fold islands and client:visible below the fold. Add rootMargin options to visible islands that sit within one fast scroll gesture of the fold, so the fetch begins before intersection — the same head-start-versus-waste tradeoff that governs native lazy loading against a hand-rolled IntersectionObserver. Viewport-conditional components move to client:media so the excluded breakpoint never fetches them.
  • [ ] 5. Verify the new fetch sequencing. Reload with the Network panel open: pre-LCP JavaScript should now be limited to the single client:load island (plus its runtime), idle islands should start after the main content settles, and visible islands should show no request until you scroll. The Priority column should show the hero image ahead of every island chunk that remains early.
  • [ ] 6. Verify hydration placement on the main thread. In a Performance trace, hydration long tasks for idle/visible islands must appear after the LCP marker. Any island still compiling before LCP either kept a stale directive or is imported by the eager island’s module graph — check the build’s chunk map for accidental shared imports.
  • [ ] 7. Re-measure LCP and TBT and lock the policy in review. Compare against the step-2 screenshot; then add a code-review rule (or lint via grep in CI) that new client:load usages require written justification, because directive drift is how the regression returns.

Steps 5 and 6 are the ones teams skip, and they are the only proof the directives actually took effect — a directive can be correct in the template and still be defeated by a shared import. This is the shape a desktop reload should have once step 4 has landed:

Network panel after step 4 — desktop reload with the JS filter on A stylised DevTools request table. The hero image transfers 190 KB at 0.20 seconds and the nav island chunk 46 KB at 0.28 seconds. A marker row states LCP at 1,610 milliseconds with 46 KB of JavaScript fetched so far. Below it the runtime and carousel chunk arrives at 2.10 seconds on the idle callback, the newsletter island fetches 400 pixels before intersection, the comments island fetches only on scroll, and the mobile drawer is never fetched because its media query does not match on desktop. Network panel after step 4 — desktop reload, JS filter on Request Priority Start Transferred Trigger hero.avif Highest 0.20 s 190 KB LCP IMAGE nav.a91f.js Low 0.28 s 46 KB PRE-LCP LCP 1,610 ms — 46 KB of JavaScript fetched so far runtime+carousel.js Low 2.10 s 98 KB IDLE 2.1 s newsletter.5c2d.js Low on scroll 22 KB +400 px comments.4d1e.js Low on scroll 30 KB ON SCROLL drawer.77ab.js not fetched NO MATCH Read down the Start column: nothing island-shaped may appear above the LCP row. An island chunk that does means a stale client:load — or a shared import dragged into the eager island's graph.

Before/After Metrics

Lab conditions: throttled 4G (9 Mbps, 150 ms RTT), 4x CPU slowdown, Astro 4 site with five React islands (runtime + islands ≈ 210 KB compressed), 190 KB hero image.

Metric All client:load Sequenced directives Change
JS bytes fetched before LCP 210 KB 46 KB −78%
Hero image finish time 2,610 ms 1,480 ms −1,130 ms
LCP 2,890 ms 1,610 ms −44%
Total Blocking Time 640 ms 130 ms −80%
Nav island interactive at 2,750 ms 1,150 ms −58%
Comments island interactive at 3,900 ms on scroll (+~450 ms after intersection) deferred by design

Note the fifth row: the one island that matters first becomes interactive earlier under sequencing, because it no longer waits behind four sibling module graphs. Deferral is not a uniform slowdown — it reallocates the critical window to the resources that define the user’s first impression. Once the first-load sequence is right, the next lever is the second page: with the critical window no longer saturated, there is spare capacity to spend on Astro’s view transitions prefetch, which fetches the next document’s HTML and island chunks while the current page sits idle.

FAQ

Will client:visible feel broken if the user scrolls quickly to an island?

It can: the module fetch starts only at intersection, so a fast flick can land the user on a still-inert component for a few hundred milliseconds. The mitigation is the directive’s options object — client:visible={{ rootMargin: '400px' }} begins hydration while the island is still up to 400 px below the fold, buying fetch time from scroll distance. For islands reachable within a single gesture from the initial viewport, client:idle is the safer trigger.

Why does client:idle still fetch almost immediately on fast machines?

Because requestIdleCallback fires as soon as the main thread has spare capacity, and on a fast device with a light page that can be a couple hundred milliseconds after load. This is the directive working as designed: its guarantee is no contention with critical-path work, not lateness. On the slow devices where contention actually damages LCP and TBT, the idle period arrives only after the critical path clears — the deferral automatically scales with how much the device needed it.

Should the hero section itself ever be an island?

Only its interactive fragment, kept minimal. Astro server-renders island HTML, so hero imagery and copy paint without any client JavaScript regardless of directive — wrapping the entire hero in a framework component to animate one button drags a full module graph into the critical window for nothing. Extract the button into its own small island on client:idle, keep the LCP-bearing markup static, and the hero paints at HTML speed while the interactivity arrives quietly afterward.


Related