---
title: "SSR, Hydration, and @defer Without Guesswork"
description: "A practical Angular rendering guide with server routes, hydration configuration, @defer boundaries, incremental hydration, and browser-only DOM safeguards."
deck: "Rendering strategy belongs in route config, measurements, and DOM constraints, not in a blanket SSR decision. [Hydration shipped stable in Angular v17 (November 2023)](https://blog.angular.dev/introducing-angular-v17-4d7033312e4b), [`@defer` in v18 (May 2024)](https://blog.angular.dev/angular-v18-is-now-available-e79d5ac0affe), and [incremental hydration in v20 (May 2025)](https://angular.dev/guide/incremental-hydration). The stack is production-ready; the design questions are what differ."
author: "André Ramos"
url: "https://andreramos.dev/angular/ssr-hydration-and-defer-without-guesswork/"
lang: "en"
type: "article"
---

# SSR, Hydration, and @defer Without Guesswork

> Rendering strategy belongs in route config, measurements, and DOM constraints, not in a blanket SSR decision. [Hydration shipped stable in Angular v17 (November 2023)](https://blog.angular.dev/introducing-angular-v17-4d7033312e4b), [`@defer` in v18 (May 2024)](https://blog.angular.dev/angular-v18-is-now-available-e79d5ac0affe), and [incremental hydration in v20 (May 2025)](https://angular.dev/guide/incremental-hydration). The stack is production-ready; the design questions are what differ.

## Make rendering a route decision

Start one level lower than "should the app use SSR?" Ask which routes need which rendering mode. A pricing page, a blog article, a logged-in dashboard, and a checkout flow do not have the same constraints.

Angular's server route configuration gives the team a place to record that decision in code. That matters because rendering strategy should survive code review, not disappear into meeting notes.

For prerendered parameterized routes, keep `inject()` calls before any `await` inside `getPrerenderParams`. That keeps the example aligned with Angular's synchronous injection-context rule.

| Route shape | Mode to test | Evidence required |
|---|---|---|
| Public marketing route | Prerender | SEO, stable content, low per-request personalization |
| Blog or docs route | Prerender with params | Known slugs at build time and acceptable build cost |
| Logged-in dashboard | Client rendering | Private data, heavy browser-only UI, SEO not relevant |
| Checkout or product detail | Server rendering | LCP and conversion improve without leaking user data |
| Unknown fallback | Server rendering or explicit 404 policy | Clear behavior for routes not generated at build time |

*Route-level render modes*

```ts
// app.routes.server.ts
import { inject } from '@angular/core';
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: '',
    renderMode: RenderMode.Prerender,
  },
  {
    path: 'pricing',
    renderMode: RenderMode.Prerender,
  },
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const posts = inject(PostCatalog);
      const slugs = await posts.publicSlugs();
      return slugs.map((slug) => ({ slug }));
    },
  },
  {
    path: 'app/**',
    renderMode: RenderMode.Client,
  },
  {
    path: 'checkout',
    renderMode: RenderMode.Server,
  },
  {
    path: '**',
    renderMode: RenderMode.Server,
  },
];
```

## Hydration is a contract, not a switch

Hydration only works when the server DOM and client DOM agree. Direct DOM manipulation, invalid HTML, CDN rewrites, and browser-only assumptions are not small details. They are the reasons a server-rendered page fails when the client tries to claim the existing HTML.

Enable hydration with event replay for public routes where early interaction matters, and make transfer cache policy explicit. User-specific endpoints should not accidentally become reusable initial HTML data.

In custom SSR setups, the hydration provider also belongs in the server bootstrap configuration. Otherwise the client and server are not agreeing on the same rendering contract.

*Hydration and transfer cache*

```ts
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
  provideClientHydration,
  withEventReplay,
  withHttpTransferCacheOptions,
} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(
      withEventReplay(),
      withHttpTransferCacheOptions({
        filter: (req) => !req.url.includes('/api/profile'),
        includeRequestsWithAuthHeaders: false,
      })
    ),
  ],
};
```

## `@defer` should move real JavaScript out of the initial path

`@defer` is not a decorative loading state. It earns its place when a standalone dependency is expensive enough that delaying it improves the initial path or the interaction path.

Review the diff by asking three questions: which component leaves the initial bundle, what placeholder prevents layout shift, and what happens if the deferred code fails to load?

The placeholder, loading, and error blocks are eagerly loaded, so keep them cheap. And in SSR or SSG, a normal `@defer` block renders its placeholder on the server; if the main content should render on the server, that moves the conversation to incremental hydration.

*Deferring a heavy report chart*

```html
<section
  class="report-route"
  aria-live="polite"
  aria-atomic="true"
>
  <app-report-summary [summary]="summary()" />

  @defer (on viewport; prefetch on idle) {
    <app-revenue-chart [series]="series()" />
  } @placeholder (minimum 600ms) {
    <app-chart-skeleton aria-label="Chart loading" />
  } @loading (after 200ms; minimum 600ms) {
    <app-chart-skeleton aria-label="Chart loading" />
  } @error {
    <app-inline-error message="Could not load the chart" />
  }
</section>
```

## Incremental hydration is for islands with a reason

Incremental hydration keeps server-rendered content visible while delaying hydration for selected sections. That makes sense for expensive sections that do not need to be interactive immediately.

Do not make it the first SSR task in a mature app. First prove normal hydration is clean, then test one route where an expensive island has a visible payoff. Angular enables event replay automatically when incremental hydration is enabled, so the team should also test early clicks and keyboard interaction.

*Enable incremental hydration*

```ts
// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideClientHydration(withIncrementalHydration()),
  ],
});
```

*Hydrate the chart when it enters the viewport*

```html
@defer (hydrate on viewport) {
  <app-revenue-chart [series]="series()" />
} @placeholder {
  <app-chart-skeleton aria-label="Chart loading" />
}
```

## Move browser-only DOM work out of render

SSR exposes code that assumes `window`, `document`, layout measurement, or a chart library exists during render. Avoid turning that into scattered `isPlatformBrowser` branches in templates.

For component DOM work that only makes sense in the browser, use hooks such as `afterNextRender`. The rendered HTML remains consistent, and the browser-only side effect runs after Angular has rendered in the browser.

*Browser-only measurement after render*

```ts
export class ChartHostComponent {
  private readonly chartHost = viewChild.required<ElementRef>('chartHost');

  constructor() {
    afterNextRender(() => {
      const width = this.chartHost().nativeElement.clientWidth;
      this.resizeChart(width);
    });
  }

  private resizeChart(width: number) {
    // browser-only chart API
  }
}
```

## The spike should produce numbers and exceptions

Rendering changes can sound bigger than their evidence. Keep the spike narrow: measure the current state, change one route or one heavy component, and record what improved, what broke, and what had to be skipped.

`ngSkipHydration` can be a tactical exception for a legacy component that mutates DOM, but it should come with a component name, reason, owner, and removal plan. If the exception list grows, hydration is telling you something about the codebase.

| Hypothesis | Minimum evidence | Decision |
|---|---|---|
| Prerender improves public content | HTML exists at build output, SEO metadata is present, build time is acceptable | Use prerender for stable public routes |
| SSR improves a critical route | LCP improves and hydration logs stay clean | Use server rendering for that route |
| `@defer` helps a heavy component | Initial bundle drops and placeholder causes no CLS | Keep defer and document the trigger |
| Incremental hydration helps a route | Early events replay and non-critical island hydrates later | Keep only for that measured route |
| Legacy DOM blocks hydration | Mismatch reproduced and owner identified | Temporary `ngSkipHydration` with removal plan |

*Rendering baseline commands*

```bash
rm -rf dist .angular/cache
time npx ng build --configuration production --stats-json
npx ng test --no-watch
npx lighthouse https://staging.example.com/critical-route --only-categories=performance
```

## Reusable artifact: Rendering strategy review checklist

- Classify each priority route as CSR, prerender, or SSR before touching configuration.
- When using `getPrerenderParams`, keep `inject()` before any `await`.
- Enable hydration only with a DOM audit: valid HTML, no server/client mismatch, no uncontrolled direct DOM mutation.
- Filter transfer cache for user-specific or sensitive endpoints.
- Use `@defer` only when a standalone heavy dependency leaves the initial path, the placeholder stays cheap, and CLS remains stable.
- Treat incremental hydration as a measured route-level experiment, not a default for the entire app.
- Document every `ngSkipHydration` exception with owner, reason, risk, and removal plan.

## Sources checked

- https://angular.dev/guide/ssr
- https://angular.dev/guide/hydration
- https://angular.dev/guide/incremental-hydration
- https://angular.dev/api/platform-browser/withEventReplay
- https://angular.dev/guide/templates/defer
- https://github.com/angular/angular/discussions/57664
- https://blog.angular.dev/introducing-angular-v17-4d7033312e4b
- https://blog.angular.dev/angular-v18-is-now-available-e79d5ac0affe
- https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301

## Related

- [NG0500 Hydration Node Mismatch: the Real Fix Is Not ngSkipHydration](https://andreramos.dev/angular/angular-hydration-mismatch-ng0500/)
- [The Code That Breaks When Angular Goes Zoneless](https://andreramos.dev/angular/code-that-breaks-when-angular-goes-zoneless/)
- [HTTP State Without Turning Everything Into Signals](https://andreramos.dev/angular/http-state-without-signals-everywhere/)
