Hydration Patterns

Hydration Patterns

Four hydration strategies — full, partial, progressive, and islands — that determine what JavaScript is shipped, when interactivity attaches to server-rendered HTML, and how TTI and bundle size trade off against each other.

Intent

Hydration is the process of attaching JavaScript behaviour to server-rendered HTML. After the server sends HTML, the browser must "hydrate" it — match the static markup with client-side component instances, attach event listeners, and restore reactivity. Without hydration, the page displays correctly but is non-interactive.

The four strategies differ in what gets hydrated and when:

  • Full hydration — the entire component tree receives JavaScript immediately
  • Partial hydration — only interactive components receive JavaScript; static components remain as server HTML permanently
  • Progressive hydration — JavaScript execution is staged; critical components hydrate first, non-critical components hydrate later
  • Islands hydration — isolated interactive components ("islands") hydrate independently on per-island triggers; surrounding content is permanent static HTML

The distinction between strategies matters most for Time to Interactive (TTI) and JavaScript bundle size. Full hydration ships the most JS and blocks TTI on the entire tree. Islands hydration ships the least JS and unblocks TTI as each island becomes ready. Partial and progressive hydration occupy the middle ground — partial cuts the bundle by excluding static components; progressive cuts perceived TTI by prioritising critical interactions.

TypeScript-only examples are used throughout this note — hydration is a frontend concern with no Java equivalent, consistent with the Micro-Frontends precedent in this vault. Examples draw from both Angular and React/Next.js/Astro ecosystems in the same note.

When NOT to Use

Five patterns to avoid — applying the wrong hydration strategy is a significant source of performance regressions and interaction bugs:

  • Do not trigger full hydration for static-only components — use @defer (hydrate never) (Angular) or React Server Components (no JS shipped) for purely presentational content that has no event handlers and never changes.
  • Do not use islands hydration with shared mutable state across islands — islands hydrate independently; module-level singletons cause race conditions because hydration order is non-deterministic.
  • Do not omit @placeholder blocks when using @defer (hydrate on interaction) — users see no visual feedback during the hydration window, leading to double-clicks and "button doesn't work" bug reports.
  • Do not use progressive hydration as a substitute for code splitting — progressive hydration changes when JS runs, not how much JS is shipped; the full bundle still loads.
  • Do not force partial hydration on highly interactive pages where most components need JS — the overhead of carving out non-hydrated subtrees exceeds the benefit when the majority of components require event listeners.

When to Use

A brief rationale per strategy — match the strategy to the page's interactivity profile:

  • Full hydration: SSR apps where every component is interactive and simplest setup is acceptable. The baseline choice — every component tree node gets JavaScript, hydration is blocking but predictable. Acceptable TTI for apps where interaction is universal.
  • Partial hydration: Content-heavy pages with few interactive widgets — blogs, documentation, marketing landing pages. Most of the HTML is static prose or layout; only a handful of components (a comment form, a newsletter signup) need JavaScript.
  • Progressive hydration: Pages where critical interactions must work immediately but non-critical sections can defer — e-commerce product pages where the add-to-cart button is critical but below-fold reviews can hydrate later. The full bundle still ships but execution is prioritised.
  • Islands hydration: Pages with isolated pockets of interactivity in a sea of static content. Astro sites and Angular apps with incremental hydration are the canonical examples. Ships the minimum possible JavaScript — only island-specific bundles.

Hydration Strategy Comparison Table

StrategyWhat gets hydratedWhenJS shippedTTI impactAngular implReact/Next.js impl
FullEntire component treeImmediately on loadFull bundleBaseline (blocking)provideClientHydration()Default Next.js behaviour
ProgressiveCritical parts first, rest deferredPriority-ordered stagesFull bundle, stagedBetter TTI on critical pathwithEventReplay() queues eventsReact <Suspense> boundaries
PartialOnly interactive componentsOn load (static = no JS)Reduced bundleBest TTI for content-heavy pages@defer (hydrate never) for static blocksReact Server Components ship no JS
IslandsIsolated interactive islandsPer island triggerMinimal (island-only)Best TTI globallyIncremental hydration (@defer hydrate on ...)Astro client:load, client:idle, client:visible

Hydration Timeline Diagram

sequenceDiagram
    participant Server as Server
    participant Browser as Browser
    participant DOM as DOM

    Server->>Browser: HTML + JS bundle

    rect rgb(220, 220, 255)
    Note over Browser,DOM: Full Hydration
    Browser->>DOM: Hydrate entire component tree (blocking)
    end

    rect rgb(220, 255, 220)
    Note over Browser,DOM: Progressive Hydration
    Browser->>DOM: Hydrate critical components first
    Browser->>DOM: Hydrate remaining on idle/viewport
    end

    rect rgb(255, 255, 220)
    Note over Browser,DOM: Partial Hydration
    Browser->>DOM: Hydrate interactive components only
    Note over DOM: Static components retain server HTML (no JS)
    end

    rect rgb(255, 220, 220)
    Note over Browser,DOM: Islands Hydration
    Browser->>DOM: Hydrate Island A on viewport
    Browser->>DOM: Hydrate Island B on interaction
    Note over DOM: Non-island HTML is permanent static content
    end

Full Hydration

Full hydration is the baseline strategy. The entire component tree is hydrated immediately on page load — all JavaScript is downloaded, parsed, and executed, and every component gets event listeners attached. It is the simplest approach to implement: add provideClientHydration() to the Angular bootstrap providers, or ship a Next.js App Router app with 'use client' components and accept the default. The trade-off is that it ships the most JavaScript and has the highest TTI cost because hydration blocks user interaction until the full tree is processed.

Full hydration makes sense when the page has no static sections — every component is interactive, every button does something, and no part of the DOM is purely presentational. News feed apps, chat applications, and real-time dashboards are typical candidates. When the page is predominantly interactive, the overhead of carving out non-hydrated subtrees (partial or islands) exceeds the benefit.

Angular — provideClientHydration

// Source: angular.dev/guide/hydration
// main.ts
import { bootstrapApplication } from '@angular/platform-browser'
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'
import { AppComponent } from './app/app.component'
import { appConfig } from './app/app.config'
 
bootstrapApplication(AppComponent, {
  providers: [
    ...appConfig.providers,
    provideClientHydration(withEventReplay()), // withEventReplay is default in Angular 19+
  ]
})

withEventReplay() is the default behaviour in Angular 19+ — it queues user events (clicks, keydowns) that fire before hydration completes and replays them after the component tree is hydrated. This means events are not lost during the hydration window, which is critical for perceived interactivity on slow connections. When using withIncrementalHydration(), event replay is included automatically.

Next.js — Default Behaviour

Next.js App Router hydrates all 'use client' components by default. Any component marked with 'use client' at the top of the file is a Client Component — it receives a JavaScript bundle and is fully hydrated on page load.

Server Components (any component without a 'use client' directive in the App Router) ship zero JavaScript and are never hydrated — they are rendered on the server, their HTML is sent to the browser, and the browser displays them as static content. This is the Next.js mechanism for partial hydration: components without 'use client' automatically opt out of the hydration process.

Progressive Hydration

Progressive hydration stages JavaScript execution. The full bundle is still shipped to the browser, but hydration is not blocked on the entire tree — critical components hydrate first, non-critical components defer to idle time or viewport entry. TTI improves for the critical interaction path because the browser prioritises hydrating the components the user is most likely to interact with immediately.

Angular — withEventReplay()

Angular's event replay mechanism is a form of progressive hydration at the interaction level. When withEventReplay() is configured (default in Angular 19+), user events that fire before any component has finished hydrating are queued — not lost — and replayed after hydration completes. The user perceives interactivity before the full component tree is hydrated because event replay bridges the gap between the first user gesture and the completion of hydration.

Combined with incremental hydration triggers (hydrate on idle, hydrate on viewport), this creates a full progressive hydration model: the browser hydrates critical components first, event replay captures interactions during the window, and non-critical components hydrate in the background.

The distinction from islands hydration is important: progressive hydration still ships the full bundle but stages execution; islands hydration ships only island-specific bundles. Progressive hydration is about scheduling; islands hydration is about bundle scoping.

Next.js — Suspense Boundaries

React <Suspense> boundaries create natural hydration stages in Next.js. Components inside a <Suspense> boundary hydrate after the fallback resolves — the page shell is interactive before all data-dependent sections have arrived and hydrated. This creates a progressive hydration effect: the user can interact with the primary content while secondary sections are still loading.

// Source: nextjs.org/docs/app/building-your-application/rendering/partial-prerendering
// app/product/[id]/page.tsx
import { Suspense } from 'react'
 
// Static cached component — hydrates as part of initial shell
async function ProductInfo({ id }: { id: string }) {
  'use cache'
  const product = await db.products.findById(id)
  return <h1>{product.name}</h1>
}
 
// Dynamic streaming component — hydrates after Suspense resolves
async function UserRecommendations({ userId }: { userId: string }) {
  const recs = await getPersonalisedRecs(userId)
  return <ul>{recs.map(r => <li key={r.id}>{r.name}</li>)}</ul>
}
 
export default function Page({ params }: { params: { id: string } }) {
  return (
    <>
      <ProductInfo id={params.id} />
      <Suspense fallback={<p>Loading recommendations...</p>}>
        <UserRecommendations userId="current" />
      </Suspense>
    </>
  )
}

Partial Hydration

Partial hydration ships JavaScript only for interactive components. Static components remain as server-rendered HTML permanently — no JavaScript is downloaded for them, no event listeners are attached, and they never become client-side component instances. This reduces the JavaScript bundle size significantly for content-heavy pages where the majority of markup is presentational.

The key insight of partial hydration is that the browser cannot distinguish server-rendered static HTML from hydrated HTML visually — both look identical. The difference is purely behavioural: hydrated components respond to user input; non-hydrated components do not. Partial hydration exploits this fact by permanently opting static components out of the JavaScript lifecycle.

Angular — @defer (hydrate never)

Marking a block with @defer (hydrate never) tells Angular to keep the server-rendered HTML in the DOM and never attach JavaScript to it. The component is purely static — Angular skips JS attachment entirely for that subtree.

<!-- Source: angular.dev/guide/incremental-hydration -->
<!-- app.component.html — static footer never hydrated -->
@defer (hydrate never) {
  <legal-footer />
}

The legal-footer component's server-rendered HTML remains in the DOM exactly as the server produced it. No JavaScript bundle is downloaded for it. Angular DevTools will show it as a non-hydrated subtree.

Next.js — React Server Components

In Next.js App Router, any component without 'use client' at the top is a Server Component. Server Components are rendered on the server, their HTML is sent to the browser, and zero JavaScript is shipped for them. The browser receives static HTML for these components — there is no client-side component instance, no event listener, no bundle entry.

This is the native partial hydration mechanism in Next.js: by default, components are Server Components unless explicitly opted into the client with 'use client'. A content-heavy page that marks only its interactive widgets (a newsletter form, a search bar) with 'use client' achieves partial hydration automatically.

Islands Hydration

Islands hydration is the most granular strategy. Isolated interactive components ("islands") are hydrated independently, each with its own trigger. The surrounding page is permanent static HTML that is never hydrated — it has no JavaScript, no event listeners, and no client-side component lifecycle. Islands provide the lowest possible JavaScript payload because each island ships only its own bundle, not the entire application tree.

The term "island" comes from the metaphor of interactive components as islands in a sea of static content. The islands architecture was described by Jason Miller in 2020 and later formalised in Astro. Angular's incremental hydration is the same concept applied within the Angular component model — each @defer block with a hydrate trigger behaves as an independently hydrated island with a declarative trigger condition.

Angular — Incremental Hydration (@defer + hydrate triggers)

Angular's incremental hydration is enabled with withIncrementalHydration() in the bootstrap providers. It builds on event replay — withEventReplay() is included automatically.

// Source: angular.dev/guide/incremental-hydration
// main.ts — enable incremental hydration
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser'
import { bootstrapApplication } from '@angular/platform-browser'
import { AppComponent } from './app/app.component'
 
bootstrapApplication(AppComponent, {
  providers: [
    provideClientHydration(withIncrementalHydration()) // includes event replay automatically
  ]
})

With incremental hydration enabled, @defer blocks in templates accept hydrate triggers that control when each block's component tree transitions from server HTML to a live client component instance:

<!-- Source: angular.dev/guide/incremental-hydration -->
<!-- Hydrate when component enters the viewport -->
@defer (hydrate on viewport) {
  <product-recommendations />
} @placeholder {
  <div class="skeleton-loader" aria-label="Loading recommendations"></div>
}
 
<!-- Hydrate on user click/keydown — for heavy interactive widgets -->
@defer (hydrate on interaction) {
  <rich-text-editor />
} @placeholder {
  <textarea placeholder="Click to edit..."></textarea>
}
 
<!-- Never hydrate — purely static server content -->
@defer (hydrate never) {
  <legal-footer />
}
Note

Angular 19 shipped event replay as stable. Incremental hydration builds on this — check angular.dev for the current stability tier of withIncrementalHydration() before applying to production.

Angular Hydrate Trigger Reference

All 8 triggers available for @defer hydrate blocks:

TriggerMechanismUse Case
hydrate on idlerequestIdleCallbackNon-critical widgets, analytics
hydrate on viewportIntersectionObserverBelow-the-fold content, infinite scroll
hydrate on interactionclick / keydownHeavy interactive widgets, editors
hydrate on hovermouseover / focusinTooltips, preview panels
hydrate on immediateAfter non-deferred content rendersCritical UI that must hydrate ASAP
hydrate on timer(500ms)Fixed delayStaggered hydration scheduling
hydrate when conditionReactive boolean expressionFeature-flagged components
hydrate neverNeverPermanent static content

Astro — Islands Architecture (client:* directives)

Astro implements islands architecture as a first-class model — every component is static by default and ships zero JavaScript unless a client:* directive is present. The directive both opts the component into client-side rendering and specifies when hydration fires.

// Source: docs.astro.build/en/concepts/islands/
// ProductCard.astro — static by default, zero JS shipped
---
import AddToCartButton from './AddToCartButton.tsx'
const { product } = Astro.props
---
<div class="card">
  <h2>{product.name}</h2>
  <!-- client:visible = hydrate when enters viewport (island) -->
  <AddToCartButton client:visible productId={product.id} />
  <!-- No client: directive = static HTML, zero JS -->
  <p class="description">{product.description}</p>
</div>

The AddToCartButton is an island — it hydrates independently when it enters the viewport. The h2 title and p description are permanent static HTML with no JavaScript.

All 5 Astro client directives:

DirectiveWhen HydratedUse Case
client:loadOn page loadCritical interactive components
client:idleOn browser idleNon-critical widgets
client:visibleWhen enters viewportBelow-fold interactive content
client:media="(query)"When media query matchesResponsive-only components
client:only="react"CSR only (no SSR)Components that cannot server-render

Rendering Context

Hydration strategy is constrained by rendering strategy choice. SSG pages produce static HTML that benefits most from partial or islands hydration — there is no reason to ship full JavaScript for a page built at compile time. SSR pages render the full tree on every request and typically use full or progressive hydration to restore the server-rendered DOM on the client. CSR pages have no server HTML — hydration does not apply because there is nothing to hydrate; the browser builds the DOM from scratch. PPR pages combine a static shell with streaming dynamic slots, naturally aligning with progressive and islands hydration — the shell hydrates first, dynamic islands hydrate as their streaming content arrives.

The cross-link between rendering and hydration works in both directions: rendering strategy selects the shape of the initial HTML the server sends; hydration strategy selects how (and whether) JavaScript is subsequently attached to that HTML. A well-optimised application makes both choices deliberately, not by accepting framework defaults.

See [[Rendering-Strategies]] for full coverage of SSR/SSG/ISR/CSR/PPR rendering strategies and their trade-off matrix.

Common Pitfalls

Warning

Pitfall 1: Hydrating Static-Only Angular Components What goes wrong: An Angular SSR app uses full hydration (provideClientHydration() only). Static footer, policy pages, and marketing copy are hydrated with the full component tree, shipping JavaScript for components that have no event handlers and never change. Why: Full hydration is the default — developers accept it without auditing which components are truly interactive. How to avoid: Audit with Angular DevTools. Mark static sections with @defer (hydrate never). The component's HTML remains in the DOM; Angular skips JS attachment entirely for that subtree. Warning signs: JS bundle size proportional to component count even for content-heavy pages.

Warning

Pitfall 2: Islands Hydration With Shared Mutable State What goes wrong: Two island components on the same page share state through a module-level singleton. One island hydrates on viewport, the other on idle. The second island reads stale state because hydration order is non-deterministic. Why: Islands hydrate independently. Module-level singletons are not coordinated across islands' separate hydration lifecycles. How to avoid: Islands must be self-contained with no shared mutable state. Cross-island communication must use URL state, localStorage events, or a micro-frontend event bus — not module-level objects. Warning signs: Race conditions in shared state between independently hydrated components. State updates in one island not reflected in another.

Warning

Pitfall 3: @defer (hydrate on interaction) With No Placeholder What goes wrong: Angular incremental hydration defers a large component. The user clicks the area before hydration completes. Angular queues the event via event replay. The component hydrates, but during the hydration window the user sees no visual feedback — the click appeared to do nothing. Why: @defer with a hydrate trigger requires a @placeholder block to show pre-hydration UI; the developer omits it. How to avoid: Always pair @defer (hydrate on ...) with a @placeholder block showing skeleton UI or a loading indicator. Event replay ensures the interaction fires after hydration, but the user needs visual confirmation the click was registered. Warning signs: Users double-clicking interactive elements. "Button doesn't work" bug reports for SSR pages.

Warning

Pitfall 4: Nested @defer Blocks With Conflicting Triggers What goes wrong: An outer @defer (hydrate on viewport) block contains an inner @defer (hydrate on interaction) block. The inner block re-hydrates unexpectedly when the outer block's trigger fires, because nested blocks must reconcile their trigger semantics. Why: Angular incremental hydration dehydrates blocks individually; nested blocks with conflicting triggers create ambiguous hydration ordering. How to avoid: Nested @defer blocks must have compatible trigger semantics. Prefer flat (non-nested) defer blocks for independently hydrated islands. If nesting is necessary, use the same trigger type or ensure inner triggers are a subset of outer triggers. Warning signs: Components hydrating at unexpected times; inner components becoming interactive before the user has interacted with the page.

ConceptRelationshipLink
Rendering StrategiesRendering strategy constrains hydration approach[[Rendering-Strategies]]
State Management PatternsIslands must be self-contained with no shared mutable state — state paradigm choice matters[[State-Management-Patterns]]
Micro-FrontendsIslands architecture is conceptually similar to micro-frontends — independently deployable interactive units[[Micro-Frontends]]
Observer PatternEvent replay during hydration is an Observer queue — events are observed, queued, and replayed[[Observer-Pattern]]

Sources

  • angular.dev/guide/incremental-hydration — Angular incremental hydration, @defer triggers, withIncrementalHydration()
  • angular.dev/guide/hydration — provideClientHydration, withEventReplay, full hydration setup
  • docs.astro.build/en/concepts/islands/ — Islands architecture, client:* directives
  • nextjs.org/docs/app/building-your-application/rendering/partial-prerendering — PPR model, Suspense streaming