Minimize Client JavaScript
Astro renders every component to static HTML at build time and ships zero JavaScript by default. That single default is the most powerful performance feature in the framework, but it only pays off if you resist the temptation to hydrate everything. The goal of this page is simple: treat client-side JavaScript as a cost you opt into deliberately, not a baseline you start from. Every kilobyte you do not ship is a kilobyte your users never download, parse, or execute.
Start from static HTML
Most of a typical page — headings, prose, images, navigation, footers, marketing copy — is purely presentational. It needs no runtime JavaScript at all. In Astro, a component written without any client:* directive renders to HTML and disappears from the client bundle entirely.
---
// src/components/PriceTable.astro
const plans = [
{ name: "Hobby", price: "$0" },
{ name: "Pro", price: "$19" },
{ name: "Team", price: "$49" },
];
---
<table>
<thead>
<tr><th>Plan</th><th>Price</th></tr>
</thead>
<tbody>
{plans.map((p) => (
<tr><td>{p.name}</td><td>{p.price}</td></tr>
))}
</tbody>
</table>
This component runs once on the server. The browser receives finished HTML — no framework runtime, no hydration, no client bundle. That is the default you should aim to keep for as much of the page as possible.
Even a UI framework component (React, Vue, Svelte) renders to static HTML when you omit the
client:*directive. The framework runtime is only shipped for components you explicitly hydrate.
Prefer islands over whole-page apps
When interactivity is genuinely required — a search box, a cart widget, a carousel — isolate it into a small island. An island is a self-contained interactive component surrounded by static HTML. Only the island’s code and its framework runtime ship to the browser; the rest of the page stays static.
---
// src/pages/index.astro
import SearchBox from "../components/SearchBox.jsx";
import Hero from "../components/Hero.astro";
---
<Hero />
<!-- Only this component hydrates; Hero stays pure HTML -->
<SearchBox client:idle />
Avoid wrapping the entire page in one giant interactive component. A single client:load at the root re-introduces the “hydrate everything” cost you are trying to avoid.
Choose the cheapest hydration directive
When you do hydrate, the directive controls when the JavaScript loads and runs. Cheaper directives defer work off the critical path.
| Directive | When it hydrates | Use for |
|---|---|---|
client:load | Immediately on page load | Above-the-fold, instantly-needed UI |
client:idle | When the main thread is idle | Low-priority widgets |
client:visible | When scrolled into the viewport | Below-the-fold components |
client:media | When a media query matches | Mobile-only or desktop-only UI |
client:only | Client-side only, skips SSR | Components that cannot render on the server |
Reach for client:load only when interactivity must be ready before the user can act. For anything below the fold, client:visible defers the cost until it is actually seen.
---
import Comments from "../components/Comments.svelte";
import Newsletter from "../components/Newsletter.vue";
---
<!-- Far down the page: load only when scrolled to -->
<Comments client:visible />
<!-- Non-critical: wait for an idle moment -->
<Newsletter client:idle />
Replace JavaScript with platform features
A surprising amount of interactivity needs no framework at all. Modern HTML and CSS cover many cases that developers reflexively reach for JavaScript to solve.
- Use
<details>/<summary>for accordions and disclosure widgets. - Use the native
popoverattribute and:popover-openfor menus and tooltips. - Use CSS scroll-snap for carousels instead of a slider library.
- Use a
<form>with a realactionfor submissions that work without hydration.
For the small glue that remains, a tiny inline <script> in an .astro file is bundled and optimized by Astro without pulling in any framework runtime:
<button id="toggle">Toggle theme</button>
<script>
const btn = document.querySelector("#toggle");
btn?.addEventListener("click", () => {
document.documentElement.classList.toggle("dark");
});
</script>
Measure what you ship
Verify your assumptions with a production build. Astro reports per-route output and you can inspect the emitted assets directly.
npm run build
Output:
12:04:51 ▶ src/pages/index.astro
12:04:51 └─ /index.html (+8ms)
12:04:51 ✓ Completed in 412ms.
12:04:51 [build] 3 page(s) built in 1.18s
12:04:51 [build] Complete!
A route with no islands produces only HTML and CSS — no .js chunks. If you see JavaScript bundles you did not expect, an unintended client:* directive or a client:only import is the usual culprit. Pair this with a Lighthouse run to confirm the JavaScript execution time stays near zero on static routes.
Best Practices
- Default to static
.astrocomponents and addclient:*only where interactivity is truly required. - Keep islands small and isolated; never hydrate the whole page through a single root component.
- Choose the lowest-cost directive available — prefer
client:visibleandclient:idleoverclient:load. - Solve interactivity with native HTML/CSS (
<details>,popover, scroll-snap) before reaching for a framework. - Inspect each production build for unexpected
.jschunks and treat them as regressions. - Use
client:onlysparingly — it ships JavaScript and skips server rendering, hurting both performance and SEO.