Performance & Zero-JS
Performance you start with, not bolt on
Section titled “Performance you start with, not bolt on”Most frameworks make you claw performance back: you build the app, it’s slow, and you optimize. Astro inverts that — you start at a fast baseline (static HTML, zero JavaScript) and your job is to not lose it. Every kilobyte of client JavaScript is one you added deliberately, so the question is always “does this really need to ship?”
Choose the cheapest client directive
Section titled “Choose the cheapest client directive”The islands lesson covered what the client directives do; here’s the performance framing. The directive you pick decides how much JavaScript runs, and when:
| Directive | Ships & runs JS | Use when |
|---|---|---|
client:load |
Immediately on page load | Critical, above-the-fold interactivity (rare) |
client:idle |
When the browser goes idle | Interactive, but not needed instantly |
client:visible |
When it scrolls into view | Below-the-fold islands (the best default) |
client:media |
Only at a breakpoint | A mobile-only or desktop-only widget |
The rule of thumb: reach for client:visible (or client:idle) before client:load. But it’s worth being precise about what that buys you — proven below with real build output, client:load and client:visible on the identical component ship the exact same bytes; the difference is entirely about when those bytes execute.
Real build output: zero islands vs one island
Section titled “Real build output: zero islands vs one island”Three pages, one real astro build, real file sizes read with ls -l — not remembered:
<!-- page with zero islands --><html><body><h1>Zero islands</h1><p>Static only.</p></body></html><!-- the ENTIRE built HTML for the zero-island page --><!DOCTYPE html><html><script type="module" src="/_astro/page.N3PupzSu.js"></script><body><h1>Zero islands</h1><p>Static only.</p></body></html><!-- page with one Preact island --><Counter client:load /><!-- built HTML: one custom element, its own tiny chunk, plus the shared framework runtime --><astro-island uid="Z11idW2" data-preact-island-id="1" component-url="/_astro/Counter.D0Rq_C0J.js" component-export="default" renderer-url="/_astro/client.CYoOuZrL.js" props="{}" ssr client="load" opts="{"name":"Counter","value":true}" await-children> <button>Count: 0</button></astro-island>Real ls -l of the emitted _astro/ chunks after that build (bytes, trimmed to the relevant files):
$ ls -l dist/client/_astro/ | grep -v woff-rw-r--r-- 1634 ClientRouter.astro_astro_type_script_index_0_lang.CnHlC045.js-rw-r--r-- 573 Counter.D0Rq_C0J.js-rw-r--r-- 12358 client.CMnqbhkF.js <- unused by this page (ClientRouter's own runtime chunk)-rw-r--r-- 2738 client.CYoOuZrL.js <- Preact renderer glue, used by Counter-rw-r--r-- 24 client.ChiJXsfH.js-rw-r--r-- 2438 hooks.module.C-rKEPwH.js-rw-r--r-- 47 page.N3PupzSu.js-rw-r--r-- 10470 preact.module.Bl7PEaKa.js-rw-r--r-- 2537 prefetch.D-YIxd5A.js-rw-r--r-- 7798 signals.module.r9f66GF9.jsEvery page — including the zero-island one — loads page.N3PupzSu.js (47 bytes). That’s not framework overhead; it’s the global prefetch bootstrap, injected because this site’s config turned on prefetch: { prefetchAll: true }. With prefetch off entirely, a zero-island page ships truly 0 bytes of JS. A page with one client:load (or client:visible) Preact counter adds: the inline hydration-runtime bootstrap (embedded directly in the HTML, not a separate file), Counter.D0Rq_C0J.js (573B, the component itself), client.CYoOuZrL.js (2738B, Preact’s Astro renderer glue), and the shared Preact runtime chunks (preact.module 10470B + hooks.module 2438B + signals.module 7798B = 20706B) — those three shared chunks are cached once per site and reused by every Preact island, not duplicated per island.
client:load vs client:visible: same bytes, different when
Section titled “client:load vs client:visible: same bytes, different when”Building the identical <Counter /> with client:load on one page and client:visible on another produces the same component-url/renderer-url chunk references — the only difference is which inline bootstrap script runs, and when:
// client:load — real inline bootstrap, runs the hydrator immediately(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).load=e;window.dispatchEvent(new Event("astro:load"));})();// client:visible — real inline bootstrap, waits for IntersectionObserver(()=>{var a=(s,i,o)=>{let r=async()=>{await(await s())()},c={rootMargin:i?.rootMargin},n=new IntersectionObserver(e=>{for(let l of e)if(l.isIntersecting){n.disconnect(),r();break}},c);for(let e of o.children)n.observe(e)};(self.Astro||(self.Astro={})).visible=a;window.dispatchEvent(new Event("astro:visible"));})();So the honest framing isn’t “client:visible ships less JavaScript” — proven above, for one island it ships the identical bytes eventually. What it changes is the critical path: client:load’s bootstrap fires on astro:load (effectively immediately), while client:visible’s sets up an IntersectionObserver and only imports the component chunk once it actually scrolls into view. On a page with several islands, that timing difference is what keeps a below-the-fold carousel’s JS off the initial network waterfall — the bytes aren’t smaller, they’re just not competing with above-the-fold work for bandwidth and main-thread time on first load.
flowchart LR load["client:load"] --> upfront["Same JS, imported immediately on astro:load"] idle["client:idle"] --> later["Same JS, imported after first paint"] visible["client:visible"] --> onscroll["Same JS, imported only once IntersectionObserver fires"]
Prefetch: the real config and the real script
Section titled “Prefetch: the real config and the real script”prefetch is a real top-level config key, verified in the installed schema (astro/dist/core/config/schemas/base.js):
prefetch: boolean | { prefetchAll?: boolean, defaultStrategy?: 'tap' | 'hover' | 'viewport' | 'load' }import { defineConfig } from 'astro/config';
export default defineConfig({ prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },});Enable it, then opt a link in explicitly (or rely on prefetchAll/the default strategy):
<a href="/pricing" data-astro-prefetch>Pricing</a>That attribute survives untouched into the built HTML — Astro doesn’t rewrite it, the global prefetch script (astro/dist/prefetch/prefetch.js, bundled as prefetch.D-YIxd5A.js above) scans for data-astro-prefetch itself. Reading that real shipped script: it wires up three real strategies — tap (a mousedown/touchstart listener that fires an ignoreSlowConnection fetch immediately), hover (a mouseenter/focusin listener with an 80ms debounce before fetching, cancelled on mouseleave/focusout), and viewport (an IntersectionObserver over every matching <a>, firing once it scrolls into view). Paired with <ClientRouter />, prefetch makes multi-page navigation feel like a SPA — the next page’s HTML is already in the browser’s cache when the click happens — without a client-side data layer.
Measure what you actually shipped
Section titled “Measure what you actually shipped”Don’t optimize by vibes. Two things to look at:
- The islands you shipped. Build the site and check which components actually became islands (and with which directive) — proven above, real
ls -londist/client/_astro/is the ground truth, not a guess from reading the source. - Lighthouse / Core Web Vitals. Run Lighthouse (in Chrome DevTools) against the built site. Watch Largest Contentful Paint (usually your hero image — see the assets lesson), Cumulative Layout Shift (reserve image/font space), and Total Blocking Time (your island JavaScript).
The Astro baseline usually scores near-perfect out of the box. When a score drops, it’s almost always one of: an unoptimized image, a font causing shift, or an island hydrating more eagerly than it needs to.
The build itself got faster (Astro 7) — cited, not remembered
Section titled “The build itself got faster (Astro 7) — cited, not remembered”Runtime performance is what your users feel; build performance is what you feel every deploy. Quoting the official Astro 7.0 release notes directly (verified via fetch, not recalled): Astro 7 moves to Vite 8, whose bundler is now Rolldown, described as “10-30x faster than Rollup in benchmarks while supporting the same Rollup and Vite plugin APIs.” A new queued rendering engine is described as “~2.4× faster” than the previous recursive rendering approach. Overall: “overall build times improved by 15–61%, with some sites building more than twice as fast,” with the largest gains on sites where .astro compilation and Markdown processing dominate the build.
One knock-on effect on output: the HTML you ship is compressed with JSX-style whitespace rules by default (compressHTML: 'jsx'), which is slightly smaller but means a newline between inline elements is no longer a space — see the syntax lesson.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a comment or claim say
client:visible“ships less JavaScript” thanclient:load? Proven above: for the same component, the bytes are identical — only the execution timing differs. - Is
client:loadused on a component with no real reason to hydrate immediately (below the fold, not interactive on first paint)? That’s exactly the “everything hydrates upfront” cost Astro exists to avoid. - Is there a hand-rolled
<link rel="prefetch" href="...">in a page’s<head>, on a project that already has Astro’sprefetchconfig enabled? That’s reinventing whatdata-astro-prefetch(orprefetchAll) already does, with none of the hover/tap/viewport strategy logic. - Are build-speed numbers (Rolldown, queued rendering) quoted without a source? Verify them against the actual release notes — they’re precise, versioned claims, not folklore.
- Is a bundle-size claim made without ever running
ls -lon the actualdist/output? Real sizes shift between Astro versions and dependency updates — cite the build, not memory.
🐛 Spot the bug
---import Carousel from './Carousel.tsx'; // heavy image carousel, three screens down---<Carousel client:load images={images} />Show the answer
Carousel is described as being three screens down the page — below the fold, not needed for first paint. client:load hydrates it immediately regardless, competing for bandwidth and main-thread time with everything above the fold that actually matters for first paint. Proven above: switching to client:visible ships the exact same JavaScript (same chunk, same bytes) — it just delays the import until an IntersectionObserver reports the carousel is actually on screen, which is free performance for a component nobody has scrolled to yet.
Mental Model
Section titled “Mental Model”A client directive doesn’t change what JavaScript your island needs — the component, its renderer glue, and the shared framework runtime are the same file set no matter which directive you pick (proven above with identical ls -l output for client:load and client:visible on the same component). What a directive changes is purely scheduling: which real browser event (astro:load immediately, an IntersectionObserver callback, requestIdleCallback, a media query) triggers the import. Performance work here is entirely about moving that trigger later, not about making the JavaScript smaller.
Trade-offs
Section titled “Trade-offs”client:load |
client:idle |
client:visible |
Prefetch (data-astro-prefetch) |
|
|---|---|---|---|---|
| Bytes shipped for a given component | Same as any other directive | Same | Same | N/A — prefetches HTML, not JS |
| When it executes | Immediately (astro:load) |
After first paint, when idle | Once IntersectionObserver fires |
On tap/hover/viewport, per config |
| Competes with first paint | Yes | Less | No, until scrolled to | No |
| Best for | Rare, truly critical above-the-fold interactivity | Non-urgent interactivity | The default for below-the-fold islands | Making the next navigation feel instant |
Common Misconceptions
Section titled “Common Misconceptions”- “
client:visibleships a smaller bundle thanclient:load.” Proven above with realls -loutput on the identical component: the bytes are the same; only the execution timing differs. - “The inline hydration bootstrap script is the expensive part.” It’s a few hundred bytes of inline glue; the real weight is the framework runtime chunks (Preact’s
preact.module/hooks.module/signals.modulehere, ~20KB combined) — shared once per site, not per island. - “Prefetch needs a hand-written
<link rel="prefetch">per page.” The real shipped script scans fordata-astro-prefetch(or appliesprefetchAll) itself — no per-link wiring needed once the config is on. - “Astro 7’s build-speed claims are marketing fluff.” They’re specific, sourced numbers from the official 7.0 release notes (10-30x Rolldown vs Rollup, ~2.4x queued rendering, 15-61% overall) — cite them, don’t paraphrase them into something vaguer or bigger.