Skip to content

Performance & Zero-JS

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?”

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="{&quot;name&quot;:&quot;Counter&quot;,&quot;value&quot;: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.js

Every 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"]
Directives change timing, not total bytes shipped

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' }
astro.config.mjs
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.

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 -l on dist/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.

  • Does a comment or claim say client:visible “ships less JavaScript” than client:load? Proven above: for the same component, the bytes are identical — only the execution timing differs.
  • Is client:load used 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’s prefetch config enabled? That’s reinventing what data-astro-prefetch (or prefetchAll) 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 -l on the actual dist/ output? Real sizes shift between Astro versions and dependency updates — cite the build, not memory.

🐛 Spot the bug

src/components/ProductGallery.astro
---
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.

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.

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
  • “client:visible ships a smaller bundle than client:load.” Proven above with real ls -l output 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.module here, ~20KB combined) — shared once per site, not per island.
  • “Prefetch needs a hand-written <link rel="prefetch"> per page.” The real shipped script scans for data-astro-prefetch (or applies prefetchAll) 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.
What is Astro's performance philosophy?
Built with real ls -l on both output directories — what is actually different between them, proven above?
<Counter client:load />
<!-- vs -->
<Counter client:visible />
On a page with zero islands, what real JavaScript still ships, and why?
What does the real prefetch config schema look like, verified from source?
What does the real, shipped prefetch script actually do with a data-astro-prefetch link, per its source?
According to the official Astro 7.0 release notes (quoted, not paraphrased), how much faster is Rolldown than Rollup, and what is the overall build-time improvement range?