Org Skills
Referenceangular-ssr

Hydration: debugging, tuning and testing

Supporting material for angular-ssr. Agents load it on demand; it ships inside the skill folder.

RawSource

Open this when the console shows a hydration warning, the client refetches data the server already had, or you are choosing hydrate triggers. Verified 2026-09 against https://angular.dev/guide/hydration, https://angular.dev/guide/incremental-hydration and https://angular.dev/api/platform-browser/provideClientHydration.

1. Mismatch errors

Hydration walks the server's DOM and expects the client's render to produce the same structure. When it does not, Angular logs NG0500 (node mismatch) or NG0502 (unexpected extra/missing nodes) and destroys the subtree, rebuilding it from scratch — so the symptom is usually a visible flicker plus a console warning, not a crash.

Cause How it shows Fix
Invalid HTML the browser repairs <p> containing a <div>, <tr> outside <tbody>, <a> inside <a> Fix the markup. The server serialises what you wrote; the browser silently restructures it
Direct DOM manipulation nativeElement.innerHTML = …, jQuery-style plugins, a third-party widget writing into the component Move it into afterNextRender, or isolate with ngSkipHydration on that element
Non-deterministic values in a template Date.now(), Math.random(), crypto.randomUUID(), an incrementing counter Compute once on the server and pass it down, or render it only in afterNextRender
Locale or timezone formatting Server in UTC, browser in another zone; different default locale Fix the locale and timezone explicitly in the formatting call
Browser extension injecting nodes Only reproduces for some users, often just in <body> Not your bug; verify in a clean profile before chasing it
Conditional on isPlatformBrowser in the template Server renders one branch, client another — by construction Render the same structure on both, and fill in browser-only detail after render

Finding it: the warning names the component and the node. Reproduce in a clean profile with extensions disabled. Then bisect the template by commenting out halves — faster than reasoning about it, because the cause is usually one line.

ngSkipHydration on an element tells Angular to destroy and re-render that subtree rather than hydrate it. It is the right tool for a third-party widget that owns its own DOM, and the wrong tool for your own invalid markup — it hides the bug and pays the re-render cost forever.

2. Transfer cache tuning

Default behaviour with provideClientHydration(): GET and HEAD responses fetched during server rendering are serialised into the HTML and replayed to the client, so the client does not repeat them. Excluded by default: requests carrying Authorization, Proxy-Authorization or Cookie headers, and credentialed requests.

import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

provideClientHydration(
  withHttpTransferCacheOptions({
    includeHeaders: ['x-request-id'],        // response headers to carry over; none by default
    includePostRequests: false,              // POSTs are not cached unless you say so
    filter: (req) => !req.url.includes('/api/private/'),
    includeRequestsWithAuthHeaders: false,   // see the warning below
  }),
);

The refetch symptom. "We turned on SSR and the browser still loads everything again" is almost always one of:

  1. The server-side requests carry an auth header or cookie, so they are excluded by design.
  2. The client request URL differs from the server one — a different base URL, a cache-busting query parameter, a timestamp.
  3. The request is a POST, and includePostRequests is false.
  4. Hydration is not actually on — check that provideClientHydration() is in the browser config, not only the server one.

Diagnose it by comparing the server's outgoing request URLs with the browser's Network tab. A single character of difference is enough to miss the cache.

includeRequestsWithAuthHeaders: true is a security decision, not a performance one. The cached payload lives in the HTML. If that HTML can be cached by a CDN, a shared proxy or a browser back-forward cache shared across profiles, one user's data reaches another. Enable it only for routes you have explicitly marked Cache-Control: private, no-store, and write the reason in the code.

withNoHttpTransferCache() disables the whole mechanism — every request then runs twice, once on the server and once in the browser. Use it to isolate a bug, never as a steady state.

3. Choosing hydrate triggers

Every @defer block renders fully on the server; the hydrate trigger only decides when its JavaScript arrives and the block becomes interactive.

Content Trigger
Static text, footer, legal, a rendered article body hydrate never — no JavaScript is ever shipped for it
Below the fold, becomes interactive when reached hydrate on viewport
A panel or accordion the user may open hydrate on interaction
A menu or tooltip hydrate on hover
Heavy but likely needed soon hydrate on idle
Gated on app state (a feature flag, a loaded permission) hydrate when <expr>
Needed immediately after the critical content hydrate on immediate

hydrate never is the one to look for first. Most pages have a header, a footer and a body of prose that ship kilobytes of component code for no interaction at all.

Give every @defer a @placeholder with reserved height. Without it the block collapses to zero height until it hydrates, and you have traded JavaScript for layout shift.

4. Testing server rendering

Unit tests will not catch SSR bugs, because TestBed always runs in a browser-like environment. The checks that do work:

  1. Build and run the real thing: ng build && node dist/<app>/server/server.mjs, then load each route class (prerendered, server-rendered, client-rendered) and read the console for NG0500/NG0502.
  2. Disable JavaScript in dev tools and reload. Server-rendered routes should still show their content; anything blank was never actually server-rendered.
  3. curl the route and read the HTML. If the content is not in the response body, SSR is not doing what you think.
  4. Check the transfer cache by comparing the server's outgoing requests to the browser's Network tab; anything appearing in both is a refetch.
  5. In end-to-end tests, assert on content visible before hydration completes — that is the property SSR exists to provide.
  6. In CI, fail the build on a hydration warning: run a headless pass over a route list and grep the console output for NG05.

5. What to report after enabling SSR

Routes:      12 prerendered, 3 server-rendered, 8 client-rendered
Hydration:   provideClientHydration() (incremental hydration + event replay included on v22)
Islands:     footer + article body `hydrate never`; reviews and related-products `hydrate on viewport`
Transfer:    9 of 11 SSR requests reused by the client; /api/me excluded (Authorization header) — accepted
Before:      LCP 3.4 s, FCP 2.1 s, no content in the initial HTML
After:       LCP 1.2 s, FCP 0.6 s, full content in the initial HTML, 0 hydration warnings
Cost:        one Node service to operate; prerender build +40 s

The last line matters as much as the others. Server rendering adds a process to operate; the report should make that trade explicit rather than leave it for whoever gets paged.