Ch. 4 · Angular

Angular SSR and Hydration Consistency

What Angular hydration checks, why browser-only branches and invalid HTML cause NG0500, and how to defer client-only content without flicker.

~6 min readadvancedupdated Oct 19, 2026

Server-side rendering gives the browser real HTML to paint before any JavaScript runs. Without hydration, Angular would then bootstrap, throw that HTML away and render everything again, which causes a visible flash and wastes the work. Hydration, enabled with provideClientHydration() since Angular 16, makes the client walk the existing DOM instead, attaching listeners and binding data to the nodes the server already produced.

That only works if the client’s first render produces the same structure the server did. When it doesn’t, you get a hydration mismatch, and that’s what interviewers probe: what counts as “the same”, what typically breaks it, and how you fix it without giving up on SSR.

What Angular actually compares

During hydration Angular walks its own template instructions alongside the server DOM. For each node it expects, it checks that a node exists at that position, that its type matches (element, text or comment) and, for elements, that the tag name matches. If any of those fail, development builds throw an error like:

NG0500: During hydration Angular expected <span> but found <div>.
Text

followed by the expected and actual DOM around that point. Related codes cover other structural problems: NG0501 for missing siblings, NG0502 for a missing node, NG0503 for DOM nodes projected in an unsupported way.

What it does not compare is text content or attribute values. If the server rendered “Updated 3 minutes ago” and the client computes “Updated 4 minutes ago”, there’s no error. Angular adopts the server’s text node and the first client render overwrites it. The user may see the text change, but structure is intact. This is a real difference from React, which reports text mismatches. Knowing it tells you where to look: an NG0500 is about elements appearing, disappearing or changing tag, never about a stale string.

The three usual causes

Branching on the platform during the first render. This is the most common one:

@Component({
  selector: 'app-saved-cart',
  template: `
    @if (isBrowser && savedItems().length) {
      <ul class="cart">…</ul>
    } @else {
      <p class="empty">Your cart is empty</p>
    }
  `,
})
export class SavedCart {
  readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  readonly savedItems = signal<CartItem[]>(
    this.isBrowser ? JSON.parse(localStorage.getItem('cart') ?? '[]') : [],
  );
}
TypeScript

On the server there’s no localStorage, so it renders the <p>. In the browser, the very first render reads two items from storage and wants a <ul>. Angular finds a <p> where it expected a <ul>, and you get NG0500. The same pattern appears with window.innerWidth checks for “mobile layout”, feature flags read from cookies only on the client, and anything based on the user’s time zone.

Invalid HTML in the template. The server serialises your template to a string; the browser’s HTML parser then rebuilds it, fixing anything illegal. A <div> inside a <p> closes the paragraph early. A <table> with <tr> directly inside gets an implied <tbody>. A nested <a> inside another <a> is split apart. Client-side rendering creates nodes directly and never hits those parser rules, so the two DOMs differ. The fix is to write valid HTML; the error message’s DOM excerpt usually makes the culprit obvious.

Touching the DOM directly. Code that uses ElementRef.nativeElement.appendChild, innerHTML, or a third-party library that rewrites its container changes the DOM outside Angular’s knowledge. If it ran on the server, the client finds extra nodes; if it runs only in the browser before hydration finishes, Angular finds nodes it didn’t create. Move that work into afterNextRender, which runs only in the browser and only after hydration, or wrap the widget in a component you exclude from hydration (see below).

There’s a fourth, environmental cause worth knowing. Angular adds HTML comments to the server output as hydration markers. Some CDN “minify HTML” features strip comments, and the client then reports NG0507 because the integrity marker is missing. If hydration breaks only in production, check the CDN settings before the code.

Fixing the cart without losing SSR

The principle: render the same thing on both sides first, then change it once you’re in the browser and hydration is done.

@Component({
  selector: 'app-saved-cart',
  template: `
    @if (savedItems().length) {
      <ul class="cart">
        @for (item of savedItems(); track item.sku) { <li>{{ item.name }}</li> }
      </ul>
    } @else {
      <p class="empty">{{ loaded() ? 'Your cart is empty' : 'Loading your cart…' }}</p>
    }
  `,
})
export class SavedCart {
  readonly savedItems = signal<CartItem[]>([]);
  readonly loaded = signal(false);

  constructor() {
    afterNextRender(() => {
      this.savedItems.set(JSON.parse(localStorage.getItem('cart') ?? '[]'));
      this.loaded.set(true);
    });
  }
}
TypeScript

Walk through it. The server renders the <p> with “Loading your cart…”. The client’s first render has an empty signal too, so it expects the same <p>, finds it, and hydration succeeds. Then afterNextRender runs (it never runs on the server), reads storage and updates the signals, and Angular swaps in the <ul> through normal change detection. The text inside the <p> differs between server and client in some cases (“Loading” versus “empty”), but as covered above, text isn’t compared.

The trade-off is a brief “Loading your cart…” for returning users. That’s honest UI. If the cart matters for the first paint, store it server-side and render it on the server for real, typically from a cookie-identified session.

Data fetched on the server shouldn’t be fetched twice

provideClientHydration() also turns on the HTTP transfer cache. GET and HEAD requests made through HttpClient during server rendering are serialised into the page, and the client’s identical requests are answered from that cache instead of the network. Without it, every SSR page makes the same API calls twice and may briefly render loading states over content that was already there.

Requests carrying Authorization or Proxy-Authorization headers (and, in recent releases, Cookie) are excluded by default, because the cached responses end up in the HTML. You can opt them in with withHttpTransferCacheOptions({ includeRequestsWithAuthHeaders: true }), but think hard about whether that HTML might be cached by a CDN and served to someone else. The same caution applies to the id option on resource(), which uses the same mechanism.

Clicks before hydration and escape hatches

Between first paint and hydration, the page looks interactive but isn’t. withEventReplay() (stable since Angular 19; from v22 it’s on by default together with incremental hydration) records events such as clicks during that window and replays them once the matching listeners exist, so an early “Add to cart” tap isn’t lost.

ngSkipHydration on a component’s host element tells Angular to discard that component’s server DOM and render it from scratch on the client. It’s useful for a third-party chart that manipulates its own DOM. It only works on component hosts (putting it on a plain element raises NG0504) and it costs you the hydration benefit for that subtree, so treat it as a temporary quarantine while you fix the real issue.

Practice question: the mismatch that only appears for some users

“Our product page throws NG0500 in development for about one tester in five. The rest see no errors. The page shows a ‘Members save 10%’ banner. Where do you look?”

Answer. A mismatch that depends on the user means the first client render depends on something the server didn’t have. I’d look at how the banner decides to show: if it reads a cookie, localStorage or a client-side auth state during the first render, members get a banner element in the browser that the server didn’t render, and Angular finds the next sibling where it expected the banner. I’d confirm by reading the error’s expected and actual DOM excerpt, which should point at the banner’s position. The fix is to make both sides agree for the first render: either have the server read the same membership cookie from the incoming request so it renders the banner too, or render nothing (or a placeholder of fixed height) on both sides and show the banner in afterNextRender. I’d also check the template for invalid nesting near the banner, since that would fail for everyone, not one in five, which is a useful way to rule it out.

Further reading

More in Angular

read ✓Angular · mid

Angular Defer Blocks and Lazy Rendering

How @defer splits a template region into its own chunk, which trigger to pick, and why SSR renders only the placeholder unless you opt in.

~7 min readread →
read ✓Angular · hard

Angular Global Error Handling

What actually reaches Angular's ErrorHandler, how to write one that never throws or loops, and where HTTP and chunk-load errors belong.

~7 min readread →
read ✓Angular · mid

Angular Host Directives and Composition

Use hostDirectives to compose disabled, pressable and tooltip behaviour into components, control which inputs leak out, and predict run order.

~6 min readread →
esc