Angular Hydration Pitfalls: What Breaks It and How to Fix It [2026]

Link copied
Angular Hydration Pitfalls: What Breaks It and How to Fix It [2026]

Angular Hydration Pitfalls: What Breaks It and How to Fix It [2026]

Server-side rendering gives you a fast first paint, and hydration decides whether you keep it. If the browser can reuse the server's DOM, the page stays put while JavaScript wakes it up. If it can't, Angular has to throw parts of the page away and build them again, which costs CPU time, can make content flicker or jump, and in development fills the console with errors. Almost every hydration problem comes from the same root cause: the browser's first render doesn't produce the same DOM the server sent.

This is lesson 9.3 of the Angular Tutorial. Lesson 9.2 followed a request through the server; this lesson picks up when the HTML reaches the browser. You'll learn how non-destructive hydration works, how to confirm it is running, the coding patterns that break it, and when ngSkipHydration is a reasonable escape hatch. Detailed mismatch debugging, reading NG0500-family errors node by node, gets its own lesson later (lesson 10.8 in the debugging module); here the goal is to avoid the mismatches in the first place.

Destructive vs non-destructive hydration #

Approach What happens when JavaScript loads User-visible effect
No hydration The app renders from scratch and replaces the server DOM Flicker; scroll position and focus can be lost; layout shift
Destructive (older approach) Same as above, but with server HTML shown while waiting Content appears early, then is rebuilt
Non-destructive (provideClientHydration()) Angular walks the existing DOM, matches nodes to the component tree, and attaches listeners and bindings No rebuild; the page stays as rendered

Non-destructive hydration is what Angular uses today. During server rendering, Angular adds small annotations to the HTML describing the structure it produced: where each component, @if block and @for row starts and how many nodes it contains. In the browser, Angular creates components as usual, but instead of creating DOM nodes it claims the existing ones, guided by those annotations. The work saved is the DOM creation; the component code, bindings and listeners still run.

Enabling and verifying it #

The CLI adds hydration when you set up SSR. If you're wiring it by hand, add the provider to the browser config; mergeApplicationConfig in app.config.server.ts carries it to the server, which is required, because the server is what writes the annotations.

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideClientHydration } from '@angular/platform-browser';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    provideClientHydration(),
  ],
};

In Angular v22, provideClientHydration() with no arguments also turns on incremental hydration and event replay, which lessons 9.4 and 9.5 cover.

To confirm it works, open the browser console on a server-rendered page in development mode. Angular logs a hydration summary with the number of components and nodes it hydrated. Angular DevTools can also overlay the page to show which components were hydrated and which were skipped. If you see no summary, check that the provider is present on both sides and that you are actually loading a server-rendered page, not a client-rendered route.

The rule: same DOM on both sides #

Hydration needs the browser's first render to produce exactly the DOM structure the server produced. Anything that makes them differ is a potential failure:

Category Example Why it breaks
Direct DOM manipulation appendChild, innerHTML =, moving nodes with ElementRef Angular doesn't know about those nodes, so its annotations don't match
Invalid HTML nesting <div> inside <p>, <a> inside <a>, <table> without <tbody> The browser's HTML parser repairs the markup, so the DOM differs from what the server wrote
Platform-dependent templates @if (isBrowser), different content per platform Server and browser render different nodes
Non-deterministic values Date.now(), Math.random(), time zones, locale formatting Text differs; structure can differ if used in conditions
Altered HTML in transit A CDN or proxy that minifies HTML and strips comments The annotations Angular relies on are removed
Inconsistent preserveWhitespaces Set differently for server and browser builds Text nodes don't line up. Keep the default (false) everywhere

Direct DOM manipulation #

This is the most common cause in real code, usually inherited from pre-SSR components or third-party libraries.

// Breaks hydration: creates DOM nodes Angular doesn't own, during rendering
@Component({ selector: 'app-badge', template: `<span #host></span>` })
export class Badge {
  private host = viewChild.required<ElementRef<HTMLElement>>('host');
  count = input(0);

  constructor() {
    effect(() => {
      this.host().nativeElement.innerHTML = `<b>${this.count()}</b>`;
    });
  }
}
// Hydration-friendly: let the template own the DOM
@Component({
  selector: 'app-badge',
  template: `<span><b>{{ count() }}</b></span>`,
})
export class Badge {
  count = input(0);
}

When you genuinely need imperative DOM work, such as a chart library that draws into a container, do it in afterNextRender. That callback runs only in the browser and only after hydration has claimed the DOM, so it doesn't interfere:

constructor() {
  afterNextRender(() => {
    renderChart(this.container().nativeElement, this.data());
  });
}

The template should render an empty, stable container on both platforms; the library fills it in afterwards.

Invalid HTML nesting #

Browsers parse HTML leniently. When the server sends <p><div>Price</div></p>, the parser closes the <p> before the <div>, producing a different tree from the one Angular serialized. The server's string looked fine; the browser's DOM doesn't match it.

<!-- Repaired by the parser, breaks hydration -->
<p class="summary">
  <div class="price">{{ price() | currency }}</div>
</p>

<table>
  @for (row of rows(); track row.id) {
    <tr><td>{{ row.name }}</td></tr>
  }
</table>

<!-- Valid: hydrates cleanly -->
<div class="summary">
  <div class="price">{{ price() | currency }}</div>
</div>

<table>
  <tbody>
    @for (row of rows(); track row.id) {
      <tr><td>{{ row.name }}</td></tr>
    }
  </tbody>
</table>

Running your rendered HTML through a validator catches these quickly. Watch for components whose host element ends up in an invalid position, such as a component with a <div> template used inside a <p>.

Platform-dependent templates #

It's tempting to hide browser-only widgets with a platform check:

// Avoid: server renders nothing, browser renders the map
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
@if (isBrowser) {
  <app-store-map />
}

The server and browser now disagree about the DOM, and the page shifts when the map appears. Better options, in order:

Option When
Render the same placeholder on both sides, then enhance in afterNextRender The widget needs browser APIs only for its behaviour
@defer (on viewport) with a @placeholder The widget is heavy and below the fold; the server renders the placeholder (see lesson 5.6)
ngSkipHydration on the widget A third-party component you can't change

For values like the current time, compute them once on the server and transfer them (lesson 9.6), or format them in the browser after hydration. Rendering new Date() directly produces different text on each side.

ngSkipHydration: the escape hatch #

Adding ngSkipHydration to a component's host element tells Angular not to hydrate that component or its children. Angular removes its server-rendered DOM and renders it again from scratch on the client, as if hydration were disabled for that subtree.

<app-legacy-carousel ngSkipHydration />

Or permanently, from the component itself:

@Component({
  selector: 'app-legacy-carousel',
  host: { ngSkipHydration: 'true' },
  templateUrl: './legacy-carousel.html',
})
export class LegacyCarousel {}

The rules:

  • Component host elements only. On a plain element it has no effect and raises error NG0504.
  • Not on the root component. That effectively disables hydration for the whole app.
  • It's a workaround, not a fix. The skipped subtree loses the benefits of hydration and may flicker when it is re-rendered. Track each use and remove it once the underlying component is fixed.

The hydration errors you'll meet #

Angular reports hydration problems with numbered errors. You don't need to memorise them, but recognising the family helps you jump to the right cause:

Error Meaning Usual cause
NG0500 Node mismatch Invalid nesting, DOM manipulation, platform-dependent template
NG0501 / NG0502 Missing siblings / missing node Nodes removed or added outside Angular
NG0503 Unsupported projection of DOM nodes Moving projected DOM nodes manually
NG0504 ngSkipHydration on an invalid node Attribute on a non-component element
NG0505 No hydration info in server response Provider missing on the server, or page not server-rendered
NG0506 Application remains unstable A long-running task keeps the app from becoming stable
NG0507 HTML content altered after SSR A CDN or proxy modified the HTML

Lesson 10.8 walks through debugging these node by node, including reading the expected-versus-actual DOM that Angular prints.

Gotchas #

Symptom Cause Fix
No hydration summary in the console Provider missing on one side, or route is client-rendered Add provideClientHydration() to app.config.ts and merge it into the server config
NG0500 on a component with a <p> wrapper Block element nested inside <p> Use a <div> or other valid container
Table rows mismatch <tr> placed directly in <table> Add an explicit <tbody>
Widget appears late and pushes content down @if (isBrowser) in the template Same placeholder on both sides, or @defer with a sized @placeholder
Works locally, NG0507 in production CDN HTML minification stripped comments Disable HTML minification or comment stripping for SSR responses
Third-party component breaks hydration Library manipulates the DOM ngSkipHydration on its wrapper, and track it as tech debt

What's next #

Lesson 9.4 deals with the gap between "visible" and "interactive": what happens to clicks and key presses that arrive before hydration finishes, how event replay captures and replays them, and which events it doesn't cover.

Try it yourself #

Load three server-rendered pages of your app in development mode and note the hydration summary for each. Search your templates for isPlatformBrowser, innerHTML and <p> elements that contain components, and fix one mismatch source. Then list every ngSkipHydration in the codebase with the reason it exists.

YouAfter enabling SSR our article page logs NG0500 for the author card. The card template is <p class=”byline”><app-avatar /> {{ name }}</p>. What’s wrong?
Claude · used search_documentationCheck what app-avatar renders. If its template contains a <div>, the server writes <p><app-avatar><div>…, and the browser’s HTML parser closes the <p> as soon as it meets the <div>. The DOM in the browser then has a different shape from the one the server serialized, which is exactly what NG0500 reports. Change the byline wrapper to a <div class="byline">, or make the avatar render inline elements only (a <span> and an <img>). Don’t reach for ngSkipHydration here: the markup is the bug, and skipping would make the card re-render on every page load.

Up next in Angular

More from this topic

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *