Debugging Angular Hydration Mismatches: NG0500 Errors and Fixes [2026]

Link copied
Debugging Angular Hydration Mismatches: NG0500 Errors and Fixes [2026]

Debugging Angular Hydration Mismatches: NG0500 Errors and Fixes [2026]

Hydration errors are the hardest Angular runtime errors to reason about because they involve two renders on two machines. The server produced HTML from one set of inputs; the browser re-ran the same components with slightly different inputs, then tried to adopt the server's DOM node by node. When they disagree, you get an NG0500-range error, and the component you're looking at is often not the component that caused it.

This is lesson 10.8 of the Angular Tutorial, and the last lesson of Module 10. It follows lesson 10.7 on route guards and builds on lesson 9.3, which covered the patterns that avoid mismatches in the first place. This lesson is the debugging side: reading hydration errors, finding the real culprit, fixing the common causes, and using ngSkipHydration and Angular DevTools correctly.

How hydration matches #

During SSR, Angular renders the page and annotates it with extra information: comment nodes that anchor @if, @for and other view containers, and serialized data describing each component's DOM. On the client, instead of creating DOM, Angular walks its views and claims the existing nodes in the order it expects them. A mismatch means the client's expectation didn't match what's in the document:

Code What Angular found Usual meaning
NG0500 A different node than expected (an element where it expected text, <div> instead of <span>) Different content rendered, invalid HTML repaired by the browser, or DOM changed by a script
NG0501 Fewer siblings than expected An @if/@for produced different output on client and server
NG0502 No node at all where one was expected Something removed or never rendered the node
NG0503 Natively created nodes passed as projectableNodes Dynamic component creation with document.createElement content
NG0504 ngSkipHydration on an invalid node Attribute used on a non-component element
NG0505 No hydration info in the response provideClientHydration() missing on the server side
NG0506 App didn't become stable in time Pending requests, timers or an effect loop delaying hydration
NG0507 HTML altered after SSR A CDN or build step removed whitespace or comment nodes

In development builds, the console message for NG0500–NG0502 includes the expected and actual DOM around the mismatch, and names the component. Always reproduce hydration bugs with a development SSR build (ng serve with SSR enabled) to get that detail.

A debugging routine #

  1. Read the expected and actual nodes in the message. They usually make the class of problem obvious: text vs element, different tag, missing sibling.
  2. Compare the two renders. View the page source (Ctrl+U, which shows the server HTML before any JavaScript ran) next to the Elements panel after hydration. Search for the component's host element in both.
  3. Disable JavaScript and reload. What you see is exactly what the server sent. If it already looks wrong (a missing <tbody>, nested links flattened), the browser repaired invalid HTML before Angular got to it.
  4. Bisect. Add ngSkipHydration to the parent component temporarily. If the error disappears, the culprit is inside it; move the attribute down the tree until you find the component. Remove it when you're done.
  5. Classify the cause with the sections below and fix it.

Culprit 1: Values that differ between server and client #

Anything that's computed independently on each side produces different output:

// ✗ different on server and client
id = Math.random().toString(36).slice(2);
renderedAt = Date.now();
greeting = new Date().getHours() < 12 ? 'Good morning' : 'Good afternoon';

The server may be in a different timezone or locale, a minute may pass between renders, and random values never match. Some only change text; others change structure (an @if on the time of day) and trigger NG0501.

Fixes:

  • Generate once, transfer. Compute the value on the server and send it to the client with TransferState (lesson 9.6), so both sides render the same thing.
  • Defer it to the client. Render a neutral placeholder on both sides, then update after hydration:
greeting = signal('Hello');

constructor() {
  afterNextRender(() => {
    this.greeting.set(new Date().getHours() < 12 ? 'Good morning' : 'Good afternoon');
  });
}

afterNextRender() only runs in the browser, after rendering, so the first client render matches the server. The same applies to IDs used for aria-labelledby or for attributes: derive them from data ('field-' + field.key) or a counter that resets per request, not from Math.random().

Culprit 2: Browser-only APIs and branches #

Code that checks typeof window, localStorage, matchMedia or the user agent renders one way on the server and another in the browser:

// ✗ server: false, client: maybe true → different DOM
isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 600px)').matches;
@if (isMobile) { <app-mobile-nav /> } @else { <app-desktop-nav /> }

Fix: let the first client render match the server, then switch. Read browser state in afterNextRender() and write it to a signal; use CSS media queries for purely visual differences, since they need no JavaScript at all. The same applies to feature flags or A/B variants fetched separately on each side: if the server rendered variant A and the client resolves variant B, the trees diverge. Resolve the flag once on the server and transfer it, or keep flag-dependent UI out of the server render.

Culprit 3: Invalid HTML nesting #

Browsers repair invalid HTML while parsing the server response, so the DOM differs from what Angular's template describes. Angular's hydration guide calls out three common cases:

Template What the browser does
<table> with <tr> directly inside Inserts a <tbody>
<div> inside <p> Closes the <p> before the <div>
<a> inside another <a> Closes the outer link first
<!-- ✗ -->
<table>@for (row of rows(); track row.id) { <tr><td>{{ row.name }}</td></tr> }</table>

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

The same applies when a component's host element is placed where its content isn't allowed (a component that renders a <div> used inside a <p>). An HTML validator run against the server output finds these quickly.

Culprit 4: DOM changed outside Angular #

Angular's documentation names direct DOM manipulation as the most common cause of hydration errors. Anything that changes the server-rendered DOM before hydration finishes breaks matching:

  • Component code using innerHTML, appendChild or moving nodes, instead of template bindings.
  • Third-party libraries that render into a container (charts, maps, rich-text editors). D3 is the example the Angular guide uses.
  • Scripts outside Angular: browser extensions, tag managers or A/B testing tools that modify the page.

Fix: move DOM work into Angular templates where you can. For libraries that must own their DOM, render an empty container on the server and initialise the library in afterNextRender(); if the component still mixes Angular and library DOM, mark it with ngSkipHydration (below).

Culprit 5: Whitespace and comment nodes #

Hydration relies on the server HTML arriving untouched, including whitespace and the comment nodes Angular uses as anchors. Two settings commonly break that:

  • HTML minification at a CDN or in a post-build step that strips comments or collapses whitespace. Angular reports this as NG0507. Turn off HTML minification for SSR responses.
  • preserveWhitespaces set differently for the server and browser builds. If you enable it, the setting must match in both TypeScript configs.

ngSkipHydration: a scalpel, not a fix #

Adding ngSkipHydration to a component's host element tells Angular to skip hydration for that component and its children: Angular discards the server-rendered DOM for that subtree and renders it from scratch on the client.

<app-sales-chart ngSkipHydration [data]="sales()" />
@Component({
  selector: 'app-sales-chart',
  host: { ngSkipHydration: 'true' },   // applies to every instance
  template: `<div #container></div>`,
})
export class SalesChart { /* ... */ }

Rules that matter:

Rule Consequence
Only valid on component host nodes On a plain <div> or a directive host, Angular throws NG0504
Applies to the whole subtree Every child component in it is client-rendered
On the root component Disables hydration for the entire app
Costs a re-render The subtree is destroyed and recreated, which can flash and hurts interaction readiness

Angular's documentation describes it as a last resort. Use it for a component that genuinely can't be made hydration-safe (usually a third-party DOM library), and use it temporarily while bisecting. Don't use it to silence a mismatch you haven't explained.

Angular DevTools hydration overlay #

The Angular DevTools browser extension shows the hydration status of each component in the Components tab, and can enable an overlay that highlights which parts of the page were hydrated. Use it to:

  • Confirm that hydration happened at all (if nothing is marked hydrated, check provideClientHydration() on both sides).
  • Find components that were skipped, for example by an ngSkipHydration you forgot to remove.
  • Check that @defer blocks with incremental hydration (lesson 9.5) are hydrated when you expect.

In development, Angular also logs a short summary to the console after hydration with the number of components and nodes hydrated and the number skipped. A rising "skipped" count after a change is an early warning.

Gotchas #

Symptom Cause Fix
NG0500 on a table row <tr> without <tbody>; browser inserted one Add <tbody> explicitly
NG0501 around an @if Condition depends on window, time or a client-only flag Same value on both sides; switch in afterNextRender()
Text flickers to a new value after load Math.random() or Date.now() in the render Generate once and transfer, or set after render
NG0507 in production only CDN minifies HTML and strips comments Disable HTML minification for SSR pages
NG0504 after adding ngSkipHydration Attribute on a non-component element Put it on a component host
Chart component always mismatches Library renders into the DOM before hydration Initialise in afterNextRender(), or ngSkipHydration on that component

What's next #

That completes Module 10: you can now read Angular's runtime errors, fix change detection surprises, track down duplicate requests, leaks and races, debug the router, and diagnose hydration mismatches. Module 11 moves to testing, starting with Vitest as Angular's default test runner and how to test signals, effects and components in a modern app.

Try it yourself #

Run your SSR app in development, open the console, and load your five most important pages. For each hydration error, follow the routine: read the expected and actual nodes, compare the page source with the hydrated DOM, and classify the culprit. Then turn on the Angular DevTools overlay and check that no component you care about is being skipped.

YouOur product page throws NG0501 pointing at ProductGallery, but only for some users. Locally it’s fine. What should I look at?
Claude · used search_documentationNG0501 means the client rendered a different number of sibling nodes than the server, almost always from an @if or @for that evaluates differently on each side. In ProductGallery, the thumbnail strip is wrapped in @if (showThumbnails), and showThumbnails reads localStorage.getItem('gallery-compact') when the browser is available. The server has no localStorage, so it always renders the strip; users who once switched to compact mode get no strip on the client, hence the missing siblings. That’s why it only affects some users. Render the default on both sides, then read the preference in afterNextRender() and write it to a showThumbnails signal. If the preference must be right on first paint, store it in a cookie instead, read it on the server, and pass it to the client with TransferState.

Up next in Angular

More from this topic

View all Angular articles →

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 *