# Hydration: debugging, tuning and testing

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.

```ts
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.
