• 22.08.2026 23:21:05
  • Admin Admin

Build an Angular CLI SSR baseline, diagnose hydration failures, reuse server HTTP data, and measure deferred bundles with repeatable browser and CI checks.

Angular CLI SSR Hydration: Debug Mismatches, Ship Less JavaScript

Establish an Angular CLI SSR and hydration baseline

Use a small route with real API data as an angular training lab; a static home page will not expose duplicate HTTP calls or hydration failures. Create an SSR-enabled project, run it locally, and fetch a deep link without a browser. The returned HTML should already contain the product title; if it only contains the application shell, you are testing client-side rendering rather than SSR.

ng new storefront --ssr --routing --style=scss
cd storefront
ng serve
curl -s http://localhost:4200/products/42 | grep -i "product"

Enable hydration in the client application configuration rather than adding a second bootstrap path. provideClientHydration() tells Angular to claim the DOM emitted by the server; withEventReplay() captures clicks and input occurring before JavaScript finishes booting, then replays them after listeners are attached. This is important for a slow mobile CPU: without replay, an early click on an SSR-rendered button can be lost.

import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(withEventReplay())
  ]
};

Record a baseline before changing templates or bundle boundaries. In Chrome DevTools, disable cache, throttle CPU 4x, and compare the Network waterfall for a cold navigation against the same route after hydration. Also run Lighthouse twice—once before and once after a change—and compare transferred JavaScript, LCP, and total blocking time rather than relying on a single score.

npx lighthouse http://localhost:4200/products/42   --only-categories=performance   --output=json --output-path=baseline.json

Debug Angular hydration mismatches before using escape hatches

Hydration matches the server DOM by node order, element type, and text structure. A timestamp created separately on the server and browser, invalid HTML that the browser reparses, or imperative DOM insertion can therefore produce an NG0500-class node mismatch and force Angular to discard work. Keep the server value stable during the claim phase, then make browser-only presentation changes in afterNextRender, whose callback is not executed during server rendering.

import { AfterViewInit, Component, ElementRef, Input, afterNextRender, inject } from '@angular/core';

@Component({
  selector: 'app-published-at',
  template: '<time>{{ publishedIso }}</time>'
})
export class PublishedAtComponent {
  @Input({ required: true }) publishedIso!: string;
  private readonly host = inject(ElementRef<HTMLElement>);

  constructor() {
    afterNextRender(() => {
      const time = this.host.nativeElement.querySelector('time');
      if (time) {
        time.textContent = new Intl.DateTimeFormat(undefined, {
          dateStyle: 'medium', timeStyle: 'short'
        }).format(new Date(this.publishedIso));
      }
    });
  }
}

Do not generate identifiers with Math.random(), call Date.now() in a template, or conditionally add nodes based on window.matchMedia() during initial render. For each suspected component, save server HTML with curl, then inspect the Elements panel before changing application state. The useful comparison is not the final DOM after scripts run; it is server HTML versus the DOM at the moment Angular starts claiming it.

Use ngSkipHydration only around a component host that a third-party library must mutate before Angular can claim it. It makes Angular destroy and recreate that subtree on the client, so applying it broadly trades a visible error for extra JavaScript work and can lose form state. Keep the boundary as small as possible and add a ticket to remove it once the library can render deterministically.

<!-- A component host, not a blanket attribute on the page root -->
<app-map-widget ngSkipHydration [coordinates]="store.location()" />

Use rxjs and HTTP transfer cache to eliminate the second fetch

SSR commonly fetches route data once on the Node server and then fetches it again immediately in the browser. Configure Angular's HTTP transfer cache so eligible responses are serialized into the HTML and consumed by the first client request. Pair it with withFetch() on the server-capable HTTP client; Fetch has better SSR compatibility than older XHR-oriented assumptions.

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import {
  provideClientHydration,
  withHttpTransferCacheOptions
} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()),
    provideClientHydration(
      withHttpTransferCacheOptions({
        filter: request => request.method === 'GET' &&
          request.url.startsWith('/api/public/')
      })
    )
  ]
};

In a data service, expose one observable per view-model and share its result within the render. This rxjs pattern prevents two async pipes or a resolver plus component subscription from issuing independent requests during the same render. Transfer cache addresses the server-to-browser handoff; shareReplay addresses multiple subscribers in one runtime, so they solve different duplication paths.

readonly product$ = this.http.get<Product>('/api/public/products/42').pipe(
  shareReplay({ bufferSize: 1, refCount: true })
);

Never cache personalized responses merely because they are GET requests. Authorization-bearing requests are excluded by default for a reason: serializing a response into HTML can expose one user's data to the wrong document or to page source. In an angular course project, verify this with DevTools: reload a public product route and confirm the browser makes zero client-side requests to that endpoint; then log in and confirm account endpoints are absent from the document's transfer state.

Measure deferred Angular bundles instead of guessing

Move expensive, below-the-fold standalone features behind @defer. A reviews widget with a markdown parser or charting dependency is a better candidate than the product title, because the latter is needed in SSR HTML. The dependency must not also be referenced eagerly in the same component file; otherwise Angular has to retain it in the initial chunk and the defer block will not provide the expected split.

@defer (on viewport) {
  <app-product-reviews [productId]="product.id" />
} @placeholder {
  <section class="reviews-skeleton" aria-label="Loading reviews"></section>
} @error {
  <p>Reviews could not be loaded.</p>
}

Build with statistics, inspect the largest initial chunks, and rerun the same route measurement after moving a dependency. This provides a before/after comparison based on bytes and request timing, not on intuition. Source maps are needed if you want the analyzer to attribute bytes to a library rather than only to a generated chunk.

ng build --configuration production --stats-json
npx webpack-bundle-analyzer dist/storefront/stats.json
npx lighthouse http://localhost:4200/products/42   --only-categories=performance --output=json --output-path=after-defer.json

Check SSR output with curl after every deferred-block change. A placeholder may be acceptable for interactive reviews, but it is usually wrong for a price, stock status, or page heading. Trigger selection and server rendering behavior determine what is present in the initial document, so treat rendered HTML as a contract and assert essential content explicitly: curl -s URL | grep -q 'In stock' should return success for inventory that must be visible without JavaScript.

Turn SSR checks into enterprise frontend framework regression tests

An enterprise frontend framework needs a test that distinguishes server output from hydrated output. With Playwright, open a route in a context where JavaScript is disabled and assert the critical text exists. Then open it normally and assert that the public API is not requested by the browser, proving that the transfer cache was consumed rather than silently bypassed.

import { test, expect } from '@playwright/test';

test('product route is SSR-rendered and does not refetch public data', async ({ browser }) => {
  const noJs = await browser.newContext({ javaScriptEnabled: false });
  const serverPage = await noJs.newPage();
  await serverPage.goto('/products/42');
  await expect(serverPage.getByRole('heading', { name: 'Trail Shoes' })).toBeVisible();
  await noJs.close();

  const page = await browser.newPage();
  const requests: string[] = [];
  page.on('request', r => {
    if (r.url().includes('/api/public/products/42')) requests.push(r.url());
  });
  await page.goto('/products/42');
  await expect(page.getByRole('heading', { name: 'Trail Shoes' })).toBeVisible();
  expect(requests).toHaveLength(0);
});

Run npx playwright test against the production-like SSR server in CI, not only against a client development server. A valuable typescript training exercise is to make the test fail intentionally by adding {{ Date.now() }} to a hydrated template, then fix it by passing a stable value from the server response. That exercise teaches the operational rule: values used during the initial DOM claim must be deterministic, while browser-specific enhancement belongs after render.

Related Course

Angular Training

Frequently Asked Questions

How do I enable SSR hydration with Angular CLI?

Create the project with ng new my-app --ssr, then add provideClientHydration() to the client application providers. Start with ng serve and use curl -s http://localhost:4200/a-deep-route to verify that meaningful route HTML is returned before JavaScript executes.

Why does an Angular hydration mismatch happen after my angular training project works locally?

Look for server/client nondeterminism: timestamps, random IDs, locale-dependent formatting, invalid HTML, and direct DOM writes are frequent causes. Capture the response with curl, inspect the pre-hydration DOM in DevTools, and move browser-only DOM changes into afterNextRender. Use ngSkipHydration only for a narrowly isolated third-party component.

How can rxjs prevent duplicate API calls in Angular SSR?

Use shareReplay({ bufferSize: 1, refCount: true }) when several subscribers consume the same in-process request, and configure withHttpTransferCacheOptions for safe public GET endpoints to bridge server and browser. In DevTools Network, a reload should show the server request but no second browser request for the cached public URL.

What should an angular course teach about @defer bundle optimization?

Teach developers to run ng build --configuration production --stats-json, inspect the output with webpack-bundle-analyzer, and compare Lighthouse JSON before and after a defer change. Ensure the deferred component is standalone and not eagerly referenced elsewhere in the same file, or the dependency may remain in the initial bundle.

AI / LLM Discovery

This article is part of Opendart Akademi's Angular training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.

Opendart Akademi llms.txt