A third-party embed — a YouTube player, a Disqus thread, an Intercom launcher, a social timeline — arrives as a bundle of JavaScript that expects to run the moment the page parses. In a traditional single-page framework, that cost is unavoidable: the component tree is one hydration boundary, so every embed’s script executes during the initial hydration pass and competes with your own interactivity for the main thread. Astro inverts this. Its islands architecture ships static HTML by default and hydrates only the specific components you mark, at the moment you choose. An embed you wrap as an island ships zero client JavaScript until its client:* directive fires — which means a below-the-fold comment widget can stay dormant until the user scrolls to it, instead of inflating Total Blocking Time (TBT) and Interaction to Next Paint (INP) on first load.

This guide covers the mechanic precisely: what each client:* directive schedules, how partial (selective) hydration turns an embed into an isolated island, and how the @astrojs/partytown integration moves analytics execution off the main thread entirely. The single most consequential decision — whether a given embed should hydrate on idle or on visibility — is large enough to warrant its own companion guide, choosing client:idle vs client:visible for embeds, which this reference sets up.


Prerequisites and When to Apply

Islands are the right tool when an embed is genuinely non-critical to first paint and can tolerate deferred interactivity. Apply this pattern when:

  • The embed renders below the fold or is off-screen at load (comments, related-content carousels, chat launchers, map widgets).
  • The embed’s script is heavy (tens to hundreds of KB) and its execution shows up as a long task in a Performance trace.
  • You control the wrapper markup — you are embedding the widget yourself, not receiving it inside a CMS blob you cannot annotate.

Do not reach for deferred hydration when the embed is the primary content of the route (a standalone video page where the player is the LCP element), or when the embed must be interactive before the user could plausibly reach it. In those cases eager hydration is correct, and the lever you want is loading priority rather than deferral — see using priority hints to control script execution.

One hard constraint: an Astro component authored in .astro is server-only and cannot be hydrated. To use a client:* directive you must author the island as a framework component — React, Preact, Vue, Svelte, or Solid — because the directive tells Astro which renderer to ship and boot on the client. A plain .astro file has no client runtime to hydrate.


Concept: What Each client:* Directive Schedules

A client:* directive is a compile-time instruction. Astro reads it, ships the component’s HTML in the initial response, and injects a small loader plus the component’s JavaScript that boots the island when the directive’s trigger fires. Until the trigger fires, no component JavaScript runs and none of the third-party embed inside it executes. The five directives differ only in when they fire:

  • client:load — hydrate immediately, as soon as the page loads. The island’s JS is parsed and executed in the initial hydration pass. Use only for genuinely above-the-fold, immediately-interactive islands.
  • client:idle — hydrate when the main thread is idle, via requestIdleCallback (falling back to a setTimeout where unsupported). You may pass a timeout option (client:idle={{timeout: 500}}) to cap how long Astro waits before forcing hydration. Good for near-fold, low-urgency embeds.
  • client:visible — hydrate only when the island scrolls into the viewport, via an IntersectionObserver. You may pass rootMargin (client:visible={{rootMargin: "200px"}}) to start hydration slightly before the element is visible. Ideal for below-the-fold embeds — their script never runs if the user never scrolls.
  • client:media — hydrate only when a CSS media query matches (client:media="(min-width: 768px)"). Use to skip an embed’s cost entirely on mobile where it is hidden or irrelevant.
  • client:only — skip server rendering entirely and hydrate purely on the client. You must name the renderer (client:only="react") because Astro has no server-rendered HTML to infer it from. Use for embeds that read window/document at module top level and cannot be server-rendered at all.
Astro client:* Directives and Their Hydration Triggers Each of the five client directives is paired with the browser mechanism that fires its hydration: client:load on the initial pass, client:idle on requestIdleCallback, client:visible on IntersectionObserver, client:media on a matched media query, and client:only on a client-only boot with no server render. Directive Hydration trigger client:load Initial hydration pass (eager) client:idle requestIdleCallback (main thread idle) client:visible IntersectionObserver (scrolls into view) client:media matchMedia query matches client:only Client-only boot, no server render

The distinction between client:load, client:idle, and client:visible is the whole game for performance. client:load charges the embed’s cost to your critical path; client:idle defers it past first paint but still runs it whether or not the user ever sees the embed; client:visible runs it only on demand. The full decision procedure — including where rootMargin and the idle timeout belong — is in the client:idle vs client:visible guide.


Implementation: Wrapping an Embed as an Island

The pattern is: author the embed as a small framework component, put the third-party script inside that component’s lifecycle so it loads only after hydration, then mount it in an .astro page with the directive that matches the embed’s position and urgency. The example below wraps a comment widget as a Preact island that injects its vendor script only once hydrated.

Step 1 — Author the embed as a framework component. Keep the third-party script out of the module top level; inject it inside useEffect (React/Preact) or onMounted (Vue) so it runs at hydration time, not at import time.

// src/components/CommentsEmbed.jsx — a Preact island.
// The vendor script is injected on hydration, never at module load,
// so nothing runs until the client:* directive fires.
import { useEffect, useRef, useState } from 'preact/hooks';

export default function CommentsEmbed({ shortname, pageUrl }) {
  const mount = useRef(null);
  const [status, setStatus] = useState('idle');

  useEffect(() => {
    // Guard against double-injection (e.g. HMR or re-mount).
    if (document.getElementById('vendor-comments')) return;
    setStatus('loading');

    // Vendor requires a config global before its loader runs.
    window.disqus_config = function () {
      this.page.url = pageUrl;
      this.page.identifier = shortname;
    };

    const s = document.createElement('script');
    s.id = 'vendor-comments';
    s.src = `https://${shortname}.disqus.com/embed.js`;
    s.async = true;
    s.setAttribute('data-timestamp', String(Date.now()));
    s.onload = () => setStatus('ready');
    s.onerror = () => setStatus('error');
    document.body.appendChild(s);
  }, [shortname, pageUrl]);

  return (
    <div ref={mount}>
      <div id="disqus_thread" aria-busy={status === 'loading'} />
      {status === 'error' && <p>Comments failed to load. Please refresh.</p>}
    </div>
  );
}

Step 2 — Mount the island with a position-appropriate directive. In the .astro page, import the component and pick the directive. A comment thread lives below the article body, so client:visible defers its cost until the reader scrolls to it. The rootMargin starts hydration a little early so the thread is ready by the time it is on screen.

---
// src/pages/blog/[slug].astro
import Layout from '../../layouts/Layout.astro';
import CommentsEmbed from '../../components/CommentsEmbed.jsx';
const { slug } = Astro.params;
---
<Layout>
  <article><!-- server-rendered post body (zero client JS) --></article>

  <!-- Island: hydrates on scroll-in, ~200px early. The vendor
       script inside CommentsEmbed does not exist in the network
       waterfall until this island hydrates. -->
  <CommentsEmbed
    client:visible={{ rootMargin: '200px' }}
    shortname="my-blog"
    pageUrl={Astro.url.href}
  />
</Layout>

Step 3 — Register the renderer once. A framework island needs its renderer integration declared. Add it to astro.config.mjs so Astro knows how to server-render and hydrate the component.

// astro.config.mjs
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';

export default defineConfig({
  integrations: [preact()],
});

Step 4 — Route analytics off the main thread with Partytown. Islands defer when an embed runs, but an analytics tag such as GA4 or GTM still executes on the main thread once it runs. The @astrojs/partytown integration relocates that execution into a Web Worker, so the tag’s parsing and execution never block interactions. Enable forward for the globals the tag calls (e.g. dataLayer.push).

// astro.config.mjs — add Partytown alongside your renderer.
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import partytown from '@astrojs/partytown';

export default defineConfig({
  integrations: [
    preact(),
    partytown({
      // Forward the analytics queue call into the worker.
      config: { forward: ['dataLayer.push', 'gtag'] },
    }),
  ],
});
---
// Any .astro layout — the type="text/partytown" attribute is the
// switch that Partytown rewrites to run this script in a worker.
---
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script type="text/partytown">
  window.dataLayer = window.dataLayer || [];
  function gtag(){ dataLayer.push(arguments); }
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXX');
</script>

The G-XXXXXXX is a placeholder for your real GA4 measurement ID. Partytown is a distinct isolation strategy from islands — it moves execution off-thread rather than deferring it — and it composes with the general technique of offloading heavy scripts to Web Workers.


Verification Checklist


Interaction with Worker Offloading and Priority Hints

Astro islands are one layer in a stack of isolation techniques, and they compose with the sibling topics rather than replacing them.

  • Offloading heavy scripts to Web Workers. Islands control when an embed’s script starts; Web Workers (via Partytown or a hand-written worker) control where it runs. A heavy analytics or A/B-testing script benefits from both: defer its start with a directive, then keep it off the main thread with Partytown so even its deferred execution costs no INP.
  • Using priority hints to control script execution. For an embed that must hydrate eagerly, client:load is only half the story — the script’s fetch still competes in the network waterfall. Pair eager hydration with fetchpriority to make sure the critical embed’s resources win contention against lower-value requests.
  • Consent gating. An embed that sets cookies (comments, social widgets, analytics) must not hydrate before the user has consented. Combine the directive with a consent check so the island mounts only after consent resolves — the enforcement pattern lives in architecting GDPR-compliant consent gating.

Troubleshooting

Failure mode: “Unable to render component” / island ships no JavaScript

Symptom: The component renders as static HTML but never becomes interactive; DevTools shows no hydration and no vendor script request.

Cause: The directive was applied to an .astro component, which has no client runtime, or the renderer integration is missing from astro.config.mjs.

Fix: Author the island as a framework component (.jsx/.vue/.svelte) and confirm the matching integration (@astrojs/react, @astrojs/preact, etc.) is registered. .astro files cannot hydrate.


Failure mode: document is not defined / window is not defined during build

Symptom: The build fails server-rendering the island because the embed’s code touches window or document at module top level.

Cause: Astro server-renders every island’s HTML first (except client:only). A third-party module that reads the DOM at import time throws in the Node/SSR environment.

Fix: Move the DOM-touching code into a hydration-time lifecycle (useEffect/onMounted) as in the implementation above, or, if the module cannot be server-rendered at all, switch to client:only="preact" to skip SSR for that island.


Failure mode: embed hydrates but never appears / duplicates on navigation

Symptom: The vendor widget mounts twice, or a stale copy persists after client-side navigation or a view transition.

Cause: The vendor script was injected without an idempotency guard, so a re-mount appends a second <script> and a second widget root.

Fix: Guard injection by ID (as shown with getElementById('vendor-comments')), and give the widget a stable mount node so re-hydration reuses it rather than duplicating.


Frequently Asked Questions

Does an island with client:visible really ship zero JavaScript until I scroll?

The island’s component JavaScript and the vendor embed inside it do not execute until the IntersectionObserver fires. Astro does ship a tiny loader stub (a few hundred bytes) plus the observer registration so it knows when to hydrate — but the component bundle and the third-party script are fetched and run only on scroll-in. In a Network trace with no scrolling, the embed’s requests never appear.

Can I hydrate a plain .astro component with client:load?

No. .astro components are server-only and have no client-side runtime to boot. The client:* directives are valid only on framework components (React, Preact, Vue, Svelte, Solid) because the directive tells Astro which renderer to ship. If you need interactivity, move the interactive part into a framework component and mount that as the island.

When should I use Partytown instead of just deferring with client:visible?

They solve different problems and often pair up. client:visible defers when a script starts; Partytown changes where it runs. For a below-the-fold embed, client:visible alone is enough. For an analytics tag that must run early but is pure background work (no DOM interactivity), Partytown moves its main-thread cost into a Web Worker so it never contends for INP. For a heavy embed that is both below the fold and background-heavy, use both. See offloading heavy scripts to Web Workers.

How do I stop a cookie-setting embed from hydrating before consent?

Gate the mount on the resolved consent state rather than relying on the directive alone. Render the island only once your consent source reports the relevant category as granted, or pass a prop the island reads before injecting the vendor script. The directive controls timing; consent controls whether the island is allowed to exist at all. The enforcement architecture is covered in architecting GDPR-compliant consent gating.


Up: Framework Integration Patterns for Third-Party Scripts