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:
- The server-side requests carry an auth header or cookie, so they are excluded by design.
- The client request URL differs from the server one — a different base URL, a cache-busting query parameter, a timestamp.
- The request is a
POST, andincludePostRequestsis false. - 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:
- 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. - Disable JavaScript in dev tools and reload. Server-rendered routes should still show their content; anything blank was never actually server-rendered.
curlthe route and read the HTML. If the content is not in the response body, SSR is not doing what you think.- Check the transfer cache by comparing the server's outgoing requests to the browser's Network tab; anything appearing in both is a refetch.
- In end-to-end tests, assert on content visible before hydration completes — that is the property SSR exists to provide.
- 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.