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 Contention, Before and After Sequencing
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 orastro buildoutput), 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:loadto 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:idleto remaining above-the-fold islands andclient:visiblebelow the fold. AddrootMarginoptions to visible islands that sit within one fast scroll gesture of the fold, so the fetch begins before intersection. Viewport-conditional components move toclient:mediaso 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:loadisland (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:loadusages require written justification, because directive drift is how the regression returns.
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.
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
- Astro Islands Loading Optimization — the parent section on island architecture, bundling, and asset scheduling in Astro
- modulepreload & ES Module Loading — flattening the module-graph waterfalls inside each island’s chunk chain
- Framework-Specific Loading Strategies — up to the topic-area root