Medium wave · 540–1600 kHz77 stn
1499kHz

client:load Is Almost Always the Wrong Directive

A long dark steampunk hall of brass street lamps where every single lamp has been switched on at full blaze at the same instant, overloading a groaning copper power station at the far end, while a few lamps beside it glow softly only where neon motion sensors detect something nearby, purple and teal haze over a wet reflective floor

Open an Astro project and search for client:load. Count the hits. Then search for client:visible, client:idle, and client:media. In most codebases the first number dwarfs the others combined.

That ratio tells you something. Astro’s whole pitch is that JavaScript is opt-in: a component renders to HTML at build time and ships nothing unless you ask. But the moment a component needs to do anything, the first directive people reach for is the one that hydrates immediately, on every page load, for every visitor, whether or not they ever scroll to it.

client:load is not wrong. It is just the most expensive answer to the question, and it is usually picked before the question was asked.


What a directive actually costs

When you write <Newsletter client:load /> on a React component, Astro does more than “add some JavaScript.” The build emits:

  • An <astro-island> custom element wrapping the server-rendered HTML
  • A component-url attribute pointing at the component’s own chunk
  • A renderer-url attribute pointing at the framework renderer
  • A props attribute containing every prop, serialized into the HTML
  • A small inline bootstrap script that defines the island element

The browser then has to download the framework runtime (React plus react-dom is tens of kilobytes compressed before your component adds anything), download the component chunk, parse and execute both, and walk the existing DOM to attach event handlers. That last step is hydration, and it runs on the main thread.

With client:load, all of that starts as soon as the page’s scripts can run. It competes with your hero image, your font loading, and the visitor’s first tap. A button in the footer gets the same priority as the search box in the header.

The framework runtime is shared across islands of the same framework, so the second React island is cheaper than the first. But the first one is paid on every page that has any island at all, and client:load guarantees it is paid up front.


The ladder

Before adding a directive, walk down this list and stop at the first rung that works.

1. No directive at all

A surprising number of client:load components never needed to hydrate. They were React components ported from a Next.js codebase, they render markup from props, and nobody checked whether they actually have client state.

If a component has no useState, no useEffect, no event handlers that do anything, and no browser APIs, delete the directive. Astro will render it to HTML at build time and ship zero bytes for it.

<!-- Before: ships React and the component chunk to render a static card -->
<PostCard post={post} client:load />

<!-- After: identical HTML, no JavaScript -->
<PostCard post={post} />

This is the single highest-return change in most audits, and it takes seconds per component.

2. A plain script tag

Mobile menu toggles, copy-to-clipboard buttons, disclosure widgets, theme switches: these are a dozen lines of DOM code. They do not need a component framework, a renderer, or a virtual DOM.

Astro processes <script> tags inside .astro components, bundles them as modules, and deduplicates them, so a component used fifty times on a page still ships its script once.

<button class="copy" data-code={code}>Copy</button>

<script>
  document.querySelectorAll<HTMLButtonElement>("button.copy").forEach((btn) => {
    btn.addEventListener("click", async () => {
      await navigator.clipboard.writeText(btn.dataset.code ?? "");
      btn.textContent = "Copied";
    });
  });
</script>

No framework runtime, no hydration pass, no serialized props. If your interaction fits in a script tag, an island is overhead.

3. client:visible

For anything below the fold, hydrate when it scrolls into view. Comment sections, newsletter forms, interactive charts halfway down an article, the “related posts” carousel: none of these need to run before the visitor gets there, and many visitors never get there.

client:visible uses an IntersectionObserver, so the island’s JavaScript is not even requested until the element approaches the viewport. Recent Astro versions accept a rootMargin option so you can start loading slightly before the element is visible, which hides the delay for fast scrollers:

<Comments postId={post.id} client:visible={{ rootMargin: "300px" }} />

4. client:idle

For things that should be interactive soon but not instantly, hydrate when the browser is idle. Astro uses requestIdleCallback where available and falls back otherwise. Newer versions also take a timeout option, which caps how long the island waits for an idle period on a busy page.

Good candidates: a header search trigger that opens a dialog, a cookie-preference control, a text-size toggle. The visitor might use them in the first few seconds, but the first few hundred milliseconds belong to rendering the page.

5. client:media

Some components are only interactive at certain viewport sizes. A hamburger menu that becomes a static nav bar on desktop should not hydrate on desktop.

<MobileNav links={links} client:media="(max-width: 48em)" />

On a wide screen this island never loads its JavaScript. It is the most underused directive in the set, mostly because people forget it exists.

6. client:load

Now you are at the bottom of the ladder, and sometimes this is the right rung. Use it when the component is above the fold, interactive, and likely to be touched immediately: a search input that is the page, a product configurator on a landing page, an editor that is the whole point of the route.

Even then, make sure the server-rendered HTML is usable before hydration. A search box should be a real <form> that submits somewhere without JavaScript. Hydration should upgrade the experience, not unlock it.

7. client:only

This skips server rendering entirely and renders the component only in the browser. It requires a framework hint, such as client:only="react", because Astro never runs the component at build time to find out.

It exists for components that genuinely cannot render on the server, usually because a dependency touches window at import time. The cost is that the server output is empty: no HTML, no content for crawlers, and a layout shift when the component appears. If you must use it, give it a fallback slot sized like the real thing:

<MapWidget client:only="react">
  <div slot="fallback" class="map-placeholder" aria-hidden="true"></div>
</MapWidget>

Treat client:only as a workaround for a library problem, not a rendering strategy.


The props trap

Every prop you pass to a hydrated island is serialized into the HTML so the client can rebuild the component’s state. That is invisible until it is not.

---
const posts = await getCollection("posts");
---
<SearchFilter posts={posts} client:load />

This looks harmless. It serializes every post entry, including whatever is in its data object, into an attribute on the island. Your page now contains your whole catalog twice: once as rendered HTML and once as escaped JSON inside an attribute. On a large site that can be hundreds of kilobytes on a page that was supposed to be static.

Two rules keep this under control:

  1. Pass the minimum shape. Map to exactly the fields the island uses before passing them.
  2. Fetch large data lazily. If the island needs a big index, emit it as a static JSON file at build time and fetch it on interaction.
---
const posts = (await getCollection("posts")).map((p) => ({
  id: p.id,
  title: p.data.title,
  tags: p.data.tags,
}));
---
<SearchFilter posts={posts} client:idle />

Props must also be serializable. Functions cannot cross the boundary, which is why you cannot pass an onClick handler from an Astro page into an island. If you find yourself wanting to, the logic belongs inside the island.


Audit it in five minutes

Count what you have:

grep -rhoE "client:(load|idle|visible|media|only)" src | sort | uniq -c | sort -rn

Then count what actually ships, per page, after a build:

npm run build
grep -c "<astro-island" dist/index.html
grep -o 'props="[^"]*"' dist/index.html | awk '{ total += length($0) } END { print total " bytes of serialized props" }'

For each client:load hit, ask three questions in order:

  1. Does this component have client state at all? If not, remove the directive.
  2. Is it above the fold and touched in the first seconds? If not, use client:visible or client:idle.
  3. Is it only interactive at one breakpoint? If so, use client:media.

Whatever survives all three is a legitimate client:load. In most content sites that is one or two components, not twenty.


The takeaway

Astro gives you a default of zero JavaScript and then hands you a dial. client:load is the dial turned all the way up. Every island you hydrate eagerly is a decision to spend the visitor’s bandwidth and main thread before they have done anything, on a component they may never use.

Climb down the ladder first. Delete directives that do nothing, replace trivial islands with script tags, defer the rest until they are visible or the browser is idle. Then, and only then, reach for client:load, and be able to say why.