Configuring client:only vs client:visible in Astro

Selecting the wrong hydration directive in Astro is not a stylistic choice — it is a streaming boundary and main-thread scheduling decision. When the directive does not match the component’s viewport position and interactivity requirements, Time to Interactive climbs, Interaction to Next Paint degrades, and the browser’s streaming pipeline stalls waiting for JavaScript it did not need yet. This guide maps each directive’s execution model to its runtime constraints and gives a deterministic workflow for diagnosing and resolving these mismatches in production Astro deployments.

The investigation starts inside Astro Islands and Client Directives, the parent reference for directive syntax, lifecycle events, and <astro-island> boundary structure.

Prerequisites

Directive execution models and streaming boundaries

Astro’s streaming SSR architecture delivers HTML in discrete chunks, letting the browser parse and render critical content before non-critical payloads arrive. Hydration directives decide when JS payloads attach to those streamed islands.

Astro directive execution timeline Two horizontal swim-lanes show how client:only and client:visible differ in when the JS bundle is requested and when hydration completes relative to the HTML stream and the LCP element. client:only client:visible 0 ms 150 ms 350 ms 600 ms 900 ms Placeholder JS fetch + parse (HIGH priority) Hydrate LCP delayed SSR HTML (streamed) Idle — awaiting viewport entry IO fires JS fetch + hydrate LCP safe

client:only skips server-side rendering entirely. Astro outputs a lightweight placeholder during SSR, but requests the framework-specific JS bundle immediately during hydration initialisation. This eliminates SSR CPU overhead at the cost of synchronous high-priority network requests that compete with critical assets — images, fonts, and the document itself — when the component sits above the fold.

client:visible streams full server-rendered HTML immediately. Hydration is deferred until an IntersectionObserver detects the element entering the viewport, aligning JS execution with browser idle time and preserving streaming throughput.

---
import InteractiveChart from '../components/InteractiveChart.jsx';
import AuthModal from '../components/AuthModal.jsx';
---





Diagnostic matrix: directive-viewport misalignment

Performance regressions in Astro islands almost always trace to a mismatch between a directive and the component’s position in the page. Use this table to map an observed symptom to a root cause.

Symptom Metric impact Likely misconfiguration Resolution
High TTI + blocked streaming TTI > 3.8 s, LCP delayed client:only above the fold Swap to client:visible, or move below fold and defer non-critical JS
Unresponsive UI on first interaction INP > 200 ms, missing event listeners client:visible on modals, auth forms, sticky headers Replace with client:load or client:only for immediate event binding
CLS spikes CLS > 0.1, layout shift on hydration Hydration expands an un-reserved container Reserve dimensions via aspect-ratio or min-height in CSS before hydration
Sequential JS waterfall Network: high latency, low concurrency client:only outside Suspense boundaries, forcing sequential chunk requests Restructure route layout to parallelise streaming chunks

Cross-reference these patterns with the broader Framework-Specific Islands & Streaming SSR architecture to validate adjacent debugging steps that apply to Next.js App Router and SvelteKit deployments.

Step 1 — Audit directive placement against viewport position

Before opening DevTools, review the page template and categorise every island.

---
// Audit helper: log directive assignments at build time.
// Remove before production; use only during local investigation.
const components = [
  { name: 'HeroVideo',       directive: 'client:only="react"', aboveFold: true  },
  { name: 'PricingTable',    directive: 'client:visible',      aboveFold: false },
  { name: 'SupportChat',     directive: 'client:visible',      aboveFold: true  }, // ← suspicious
];

components
  .filter(c => c.aboveFold && c.directive.includes('client:visible'))
  .forEach(c => console.warn(`[AUDIT] Above-fold island using client:visible: ${c.name}`));
---

Expected output for the suspicious entry above:

[AUDIT] Above-fold island using client:visible: SupportChat

Any above-fold island flagged here warrants immediate directive review. A support chat widget that must respond to a click before the user scrolls needs client:load, not client:visible.

Step 2 — Record a Performance trace and filter Astro markers

Run a production build, start the preview server, and capture a cold-load trace.

# Build and serve in production mode
astro build && astro preview

In Chrome DevTools:

  1. Open the Performance tab.
  2. Click the settings gear and enable Screenshots and Web Vitals.
  3. Click Record, reload the page, wait for the load event, then stop.
  4. In the trace search bar, filter by astro:island. You will see four marker types:
    • astro:island — SSR chunk delivery for that island
    • astro:hydrate — hydration trigger fired
    • astro:visible — IntersectionObserver fired (only for client:visible)
    • astro:ready — hydration complete, event listeners attached

A healthy client:visible island shows astro:island early in the trace (streamed with the HTML), then a gap, then astro:visible and astro:ready later when the user has scrolled. A misconfigured client:only island above the fold shows astro:hydrate firing immediately after page load, pushing the LCP marker to the right.

Step 3 — Measure hydration delta and inspect the network waterfall

Calculate the gap between trigger and readiness:

// Run in the DevTools Console after page load.
// Reports hydration delta per island — delta > 150 ms signals heavy JS parse overhead.
const marks = performance.getEntriesByType('mark');
const visible = marks.filter(m => m.name === 'astro:visible');
const ready   = marks.filter(m => m.name === 'astro:ready');

visible.forEach((v, i) => {
  const r = ready[i];
  if (r) {
    const delta = (r.startTime - v.startTime).toFixed(1);
    console.log(`Island ${i}: trigger → ready = ${delta} ms`);
  }
});

A delta above 150 ms indicates heavy JS parse or compile overhead, or main-thread contention from another island hydrating at the same moment. Switch to the Network tab, filter by JS, and verify chunk request ordering. client:only islands should appear as High priority requests; if they arrive before or alongside the document or LCP image, streaming is disrupted and the directives need rebalancing.

Step 4 — Tune IntersectionObserver rootMargin and threshold

The default client:visible threshold is 0 — the island hydrates the moment any pixel enters the viewport. This can cause a noticeable lag if the JS bundle is large. Pre-empt hydration by expanding the root margin so the browser fetches and parses JS while the island is still slightly off-screen.


Validate that the observer fires at the correct moment using the astro:visible mark:

performance.getEntriesByType('mark')
  .filter(m => m.name === 'astro:visible')
  .forEach(m => console.log(`astro:visible at ${m.startTime.toFixed(0)} ms`));

Cross-reference those timestamps with the scroll position logged at the same moment to confirm the margin is expanding hydration correctly without causing unnecessary early loading.

Step 5 — Build-time conditional directive assignment

For pages where directive selection depends on route context or A/B test variants, assign directives dynamically at build time rather than maintaining separate templates. This prevents manual directive drift as the component library grows.

---
import InteractiveChart from '../components/InteractiveChart.jsx';
import SupportChat      from '../components/SupportChat.jsx';

// Derived from route metadata, CMS priority field, or Astro.props
const chartPriority = Astro.props.priority ?? 'low';
const isAboveFold   = chartPriority === 'high';
---

{isAboveFold ? (
  // Above fold: hydrate immediately so interactions are available without scroll
  
) : (
  // Below fold: defer until viewport entry to protect the streaming critical path
  
)}


Verification

After re-assigning directives, verify each Core Web Vitals target using Lighthouse against the preview server:

lighthouse http://localhost:4321 \
  --only-categories=performance \
  --preset=desktop \
  --output=json \
  --output-path=./lh-report.json \
  --view

Target thresholds:

  • LCP < 2.5 s — confirm no client:only island competes with the LCP image or font in the network waterfall
  • INP < 200 ms — verify astro:ready completes within 50 ms of the first simulated interaction
  • CLS < 0.1 — confirm island containers have min-height or aspect-ratio reserved in CSS before hydration

To enforce these targets in CI, integrate the Lighthouse Node API or @astrojs/lighthouse into your pipeline and fail the build if any metric regresses:

// lighthouse-ci.config.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        'categories:performance':            ['error', { minScore: 0.9 }],
        'largest-contentful-paint':          ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift':           ['error', { maxNumericValue: 0.1 }],
        'experimental-interaction-to-next-paint': ['warn', { maxNumericValue: 200 }],
      },
    },
  },
};

Troubleshooting

client:only is blocking my LCP image

client:only skips SSR and immediately requests the JS bundle at hydration initialisation. If the component sits above the fold, that bundle competes with the LCP image or font for network bandwidth. Move the component below the fold, switch to client:load with fetchpriority="low" on the bundle, or restructure the page so the LCP image loads before Astro’s hydration scheduler initialises.

My client:visible modal is unresponsive on the first click

client:visible defers hydration until IntersectionObserver fires. A modal triggered by a button above the fold may open before the island has hydrated, leaving it without event listeners. For any UI element that can appear before the user scrolls — modals, drawers, sticky headers, auth forms — use client:load or client:only to guarantee immediate event binding.

CLS spikes after switching to client:visible

When hydration runs, the framework component may expand the island container to its real height, pushing surrounding content down. Reserve the container’s dimensions in CSS before hydration completes:

/* Reserve space for the island so content does not shift on hydration */
.chart-island {
  min-height: 320px;    /* match the component's rendered height */
  aspect-ratio: 16 / 9; /* or use aspect-ratio for responsive containers */
}

Apply the class to the wrapper element in your Astro template:

<div class="chart-island">
  
</div>

← Back to Astro Islands and Client Directives