With SSR or prerender, your components run twice: once in Node — on each request or at build time — to produce HTML, and once more in the browser, during hydration. Constructors, field initialisers, ngOnInit, effect(), services, interceptors and guards run on both platforms. Only a few APIs are reserved for the browser: afterNextRender, afterEveryRender, afterRenderEffect and, of course, the user’s events.
On the server there is no window, no localStorage, no layout and no observers. There is a document — emulated by Domino — but only through inject(DOCUMENT). Code written “for the browser” works perfectly in ng serve without SSR and only fails in the server build. Or, worse, it doesn’t fail, but sends wrong HTML: the wrong theme, missing data, every request done twice.
- There is no
window,documentorlocalStorageon the server. Code that needs them runs inafterNextRender. - What you loaded on the server reaches the browser through the HTTP transfer cache, so
HttpClientdoes not repeat the request during hydration. - The server sends the page only when the app has no pending tasks left (
PendingTasks). A timer that never stops keeps the request open. - A page that does not exist must answer with status 404, not just display “not found”.
- Module-level variables are shared by every request on the server. A user’s state belongs in services, not there.
What doesn’t exist on the server
The Angular server runs in Node, with a DOM emulated by Domino (included in @angular/platform-server). The important distinction: the browser globals are completely missing and give a ReferenceError, while the document received through DI has structure but no layout. The table is verified on Node 22/24 and on the Domino of @angular/platform-server 22.2.2.
| API | On the server | What to use instead |
|---|---|---|
window, document (global), location, history | doesn’t exist: ReferenceError: window is not defined | inject(DOCUMENT), Router, Location |
localStorage, sessionStorage | don’t exist | afterNextRender; for what matters at render time, a cookie read from REQUEST |
matchMedia, innerWidth | missing on document.defaultView too (the Domino window) | CSS media queries; BreakpointObserver from CDK (on the server the queries don’t match); afterNextRender |
IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFrame | don’t exist | @defer (on viewport); afterNextRender + DestroyRef |
getBoundingClientRect(), offsetWidth, scrollHeight | Domino elements don’t have them (TypeError or undefined) | afterNextRender({ read }) |
document.cookie | the getter throws Error: NotYetImplemented | inject(REQUEST)?.headers.get('cookie') |
navigator | exists in Node 21+, but it belongs to the server (userAgent: 'Node.js/22') | the headers from REQUEST; isPlatformBrowser |
setTimeout, setInterval, fetch | exist, but timers don’t stop on their own, and fetch doesn’t accept relative URLs | afterNextRender + DestroyRef; HttpClient |
What runs where
| Code | Server | Browser |
|---|---|---|
field initializers, constructor, ngOnInit, ngOnChanges | yes | yes |
effect(), computed(), the template, pipes | yes | yes |
| services, interceptors, guards, resolvers | yes — new instances on each request | yes |
ngOnDestroy, DestroyRef.onDestroy | yes — the server destroys the application after rendering | yes |
afterNextRender, afterEveryRender, afterRenderEffect | no (NOOP) | yes |
event listeners: (click), (input)… | no — nobody clicks on the server | yes |
@defer without hydrate | only the @placeholder | the triggers start here |
theme = 'light';
constructor() {
this.theme = localStorage.getItem('theme') ?? 'light';
}ReferenceError: localStorage is not definedWhy: The constructor also runs on the server, and in Node there is no localStorage. Even if there were, the server has no access to the user’s storage.
readonly theme = signal('light');
constructor() {
afterNextRender(() =>
this.theme.set(localStorage.getItem('theme') ?? 'light'));
}
// or: a cookie read on the server from inject(REQUEST)ngOnInit or effect(). Everything that touches the DOM, the layout, the storage or a browser library goes into afterNextRender / afterRenderEffect — or behind a service with a separate server implementation.afterNextRender and the phases
afterNextRender and afterEveryRender (stable since v20, when afterRender was renamed afterEveryRender) register callbacks that run after Angular has finished rendering — only in the browser. On the server they are NOOPs: the callback isn’t even registered. That’s why they are the default place for any code that touches the DOM, the layout or the window APIs. They must be called in an injection context (constructor, field initializer) or with the injector option, and they clean up by themselves when the component is destroyed.
You can give them a function or an object with phases. The phases run in the order earlyRead → write → mixedReadWrite → read, and each one receives the value returned by the previous phase. The separation exists so that Angular can group the reads and writes of the whole application: a layout read after a write forces the browser to recalculate the layout on the spot (layout thrashing). A plain function runs in mixedReadWrite, the phase the documentation asks you to avoid.
| Phase | You may | Example |
|---|---|---|
earlyRead | only reads, before the writes | you measure something a write depends on (custom layout) |
write | only writes | styles, initialising a library |
mixedReadWrite | reads and writes mixed | only if the work can’t be split |
read | only reads, after the writes | you measure the final result and put it in a signal |
import { Component, ElementRef, afterNextRender, input, viewChild } from '@angular/core';
/** Shrinks the title until it fits on one line. On the server nothing happens. */
@Component({
selector: 'app-fit-title',
template: `<h2 #title class="fit">{{ text() }}</h2>`,
})
export class FitTitle {
readonly text = input.required<string>();
private readonly title = viewChild.required<ElementRef<HTMLElement>>('title');
constructor() {
// Once, after the first render, ONLY in the browser (on the server it's a no-op).
afterNextRender({
// 1. earlyRead: read the layout before any write
earlyRead: () => {
const el = this.title().nativeElement;
return Math.min(1, el.clientWidth / el.scrollWidth); // 1 = it fits
},
// 2. write: receives the result of the previous phase and only writes
write: (ratio) => {
this.title().nativeElement.style.fontSize = `${Math.floor(32 * ratio)}px`;
},
});
}
}| Expression | Here (live) | On the server it would be |
|---|---|---|
inject(PLATFORM_ID) | 'server' | 'server' |
isPlatformBrowser(id) | false | false |
isPlatformServer(id) | true | true |
inject(REQUEST) | null | Request (url, headers, cookie) |
inject(RESPONSE_INIT) | null | { status, headers } |
- +0.1 msconstructorisConnected=false, width=0px, children=0, binding=null
- +0.3 msngOnInitisConnected=true, width=0px, children=0, binding=""
On the server only the constructor and ngOnInit rows would run (no layout: the widths wouldn’t exist); afterNextRender and afterEveryRender are ignored.
afterEveryRender or afterRenderEffect
afterEveryRender runs after every render of the application, including when something changed in a completely different component. If your work depends on some signals, afterRenderEffect is the cheaper option: an effect that runs after rendering only when its dependencies change (and again only in the browser). The difference from effect(): effect also runs on the server and has no read/write phases.
import { Component, ElementRef, afterRenderEffect, input, viewChild } from '@angular/core';
@Component({
selector: 'app-chat-log',
template: `
<div #scroller class="messages">
@for (m of messages(); track m.id) { <p>{{ m.text }}</p> }
</div>
`,
})
export class ChatLog {
readonly messages = input.required<readonly { id: number; text: string }[]>();
private readonly scroller = viewChild.required<ElementRef<HTMLElement>>('scroller');
constructor() {
// ✗ would run after EVERY render of the application, even if the list hasn't changed:
// afterEveryRender({ write: () => this.scrollToBottom() });
// ✓ runs after rendering only when the signals it reads have changed (still only in the browser)
afterRenderEffect({
write: () => {
this.messages(); // dependency
this.scrollToBottom();
},
});
}
private scrollToBottom(): void {
const el = this.scroller().nativeElement;
el.scrollTop = el.scrollHeight;
}
}afterNextRender callback runs. Read and write the DOM carefully and don’t move nodes that Angular renders — otherwise the mismatch errors described in Hydration appear.Libraries that touch window
Many libraries (maps, charts, rich-text editors) access window right at import. The server crashes when it loads the module, before any component runs, and no check in the constructor saves you any more. The solution is a dynamic import() inside afterNextRender; as a bonus, the library ends up in a separate chunk. For whole components, @defer does the same declaratively.
import { Component, DestroyRef, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
// ✗ import * as L from 'leaflet'; — the module touches window as soon as it is imported:
// the server crashes when it loads the file, before any component runs.
@Component({
selector: 'app-store-map',
template: `<div #host class="map" aria-label="Store map"></div>`,
// a fixed height reserves the space in the server HTML too → no layout shift
styles: `.map { height: 320px; }`,
})
export class StoreMap {
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
private readonly destroyRef = inject(DestroyRef); // inject() only synchronously, not after await
constructor() {
afterNextRender(async () => {
const L = await import('leaflet'); // separate chunk, downloaded only in the browser
if (this.destroyRef.destroyed) return; // the user left in the meantime
const map = L.map(this.host().nativeElement).setView([44.4268, 26.1025], 12);
this.destroyRef.onDestroy(() => map.remove());
});
}
}If the library rewrites the DOM that Angular rendered, hydration complains about mismatches. ngSkipHydration on the host component stops hydration only for it — it’s a patch, not a solution.
DOCUMENT, PLATFORM_ID and platform providers
inject(DOCUMENT) — from @angular/core since v20; @angular/common re-exports it — gives you the current document on any platform. On the server it is the Domino document of the request: you can read and write the structure (title, <meta>, <link rel="canonical">, attributes on <html>), and the changes end up in the HTML that is sent. You have no layout, cookies or matchMedia.
import { DOCUMENT, Service, inject } from '@angular/core';
@Service()
export class CanonicalLink {
private readonly document = inject(DOCUMENT); // on the server: the Domino document of the request
/** Also runs on the server: the tag ends up in the HTML that crawlers see. */
set(url: string): void {
let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.rel = 'canonical';
this.document.head.appendChild(link);
}
link.href = url;
}
}
// For <title> and <meta> there are already SSR-safe services in @angular/platform-browser:
// inject(Title).setTitle('Phone X — Store');
// inject(Meta).updateTag({ name: 'description', content: '…' });inject(PLATFORM_ID) returns 'browser' or 'server', and you test it with isPlatformBrowser / isPlatformServer from @angular/common. It works, but the official guide recommends platform providers instead of runtime checks, for two reasons: the checks multiply through all the code, and used in an @if in the template they produce different content on the server and in the browser, that is a layout shift and hydration errors. Keep it for side effects that don’t change what is rendered.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, Service, inject } from '@angular/core';
@Service()
export class Analytics {
// PLATFORM_ID is 'browser' or 'server'; don't compare it by hand, use the helpers from @angular/common
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
track(event: string): void {
if (!this.isBrowser) return; // ok: a side effect that doesn't change what is rendered
navigator.sendBeacon('/collect', JSON.stringify({ event, at: Date.now() }));
}
}
// ✗ NOT like this — server and browser render different content → layout shift and hydration errors:
// @if (isBrowser) { <app-recommendations /> } @else { <p>Loading…</p> }
// ✓ use @defer (on viewport) or afterNextRender for what only makes sense in the browser.When the platforms need different behaviour, the clean approach is a contract with two implementations: the components inject the abstract class, app.config.ts provides the browser implementation, and app.config.server.ts overrides it. No if in the business code — the same principle as in Providers and Injection tokens.
import { Injectable } from '@angular/core';
// The contract: components depend only on the abstract class.
export abstract class AnalyticsService {
abstract trackEvent(name: string): void;
}
@Injectable()
export class BrowserAnalyticsService implements AnalyticsService {
trackEvent(name: string): void {
navigator.sendBeacon('/collect', name); // browser API — allowed here
}
}
@Injectable()
export class ServerAnalyticsService implements AnalyticsService {
trackEvent(_name: string): void {
// on the server: nothing (or a log), no browser APIs
}
}TransferState
Without anything special, an SSR page loads its data twice: once on the server, to render the HTML, and again in the browser, because during hydration the components start from zero, with an empty state. The result: an extra request and the content from the server briefly replaced by a skeleton.
TransferState is a key → value dictionary that travels inside the HTML. On the server you write into it; before serialisation Angular calls toJson() and puts the result in a <script id="ng-state" type="application/json"> at the end of body — only if it isn’t empty. In the browser, on the first injection, TransferState reads the script back. The keys are created with makeStateKey<T>('…'), which also binds the type of the value. The full API: get(key, default), set, remove, hasKey, isEmpty, onSerialize, toJson.
import { isPlatformServer } from '@angular/common';
import { PLATFORM_ID, Service, TransferState, inject, makeStateKey } from '@angular/core';
export interface Article { slug: string; title: string; body: string }
// Typed key: TransferState knows that an Article sits under it
const articleKey = (slug: string) => makeStateKey<Article>(`article:${slug}`);
@Service()
export class ArticleStore {
private readonly cms = inject(CmsClient); // SDK with its own fetch → does NOT go through HttpClient
private readonly state = inject(TransferState);
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
async load(slug: string): Promise<Article> {
const key = articleKey(slug);
const cached = this.state.get(key, null);
if (cached) {
this.state.remove(key); // used only once: later navigations ask for fresh data
return cached;
}
const article = await this.cms.getArticle(slug);
if (this.isServer) this.state.set(key, article); // ends up in <script id="ng-state">
return article;
}
}HTML sent by the server
Nothing yet. Start the rendering on the server.
What the user sees
/api/products/3The values go through JSON.stringify / JSON.parse: a Date becomes a string, a Map and a Set become {}, class instances lose their methods. Transfer simple DTOs (see Interfaces & models). Anything you put there is visible in the page source and makes the HTML heavier: the demo shows how many characters it adds.
HttpClient are transferred automatically (next section). Manual TransferState is for data that doesn’t go through HttpClient: SDKs (Firebase, a CMS), a direct fetch, expensive calculations done once on the server. For resources, from v22 there is also resource({ id }).The HTTP transfer cache
provideClientHydration() starts, by default, an interceptor that applies exactly the pattern above to HttpClient. On the server it saves the responses in TransferState, under a key computed as a SHA-256 hash of the method, the response type, the URL, the body and the parameters. In the browser it serves them from the cache — but only until the application becomes stable for the first time; after that everything goes to the network, as usual. httpResource goes through HttpClient, so it benefits automatically, and so do your interceptors (see HTTP).
In 22.2.2 only GET and HEAD requests that meet these conditions are cached by default:
- they have no
Authorization,Proxy-AuthorizationorCookieheaders; - they aren’t sent with
withCredentialsor withcredentials: 'include'/'same-origin'; - they have no
Cache-Controlwithno-store,no-cacheorprivate(neither the request nor the response), and don’t usecache: 'no-store'/'no-cache'; - their response doesn’t contain
Set-Cookie.
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
// The transfer cache is ON by default with provideClientHydration();
// withHttpTransferCacheOptions only tunes it.
provideClientHydration(
withHttpTransferCacheOptions({
// which response headers are saved (default: none)
includeHeaders: ['ETag'],
// false → the request is not cached (per-user data, data that changes often)
filter: (req) => !req.url.includes('/api/cart'),
// read-only POSTs (e.g. GraphQL) — only if they are idempotent
includePostRequests: true,
// default false: requests with Authorization / Proxy-Authorization / Cookie are skipped
includeRequestsWithAuthHeaders: false,
}),
),
],
};| Option | Default | When to change it |
|---|---|---|
filter(req) | all eligible requests | you remove per-user or very volatile endpoints (false = not cached) |
includeHeaders | no header | the browser needs ETag, Link… Never sensitive headers |
includePostRequests | false | a POST used for reads (GraphQL), only if it is idempotent |
includeRequestsWithAuthHeaders | false | the auth header doesn’t change the response (a public key) |
includeRequestsWithCredentials | false | responses with credentials are surely public |
includeNonCacheableRequests | false | you consciously ignore Cache-Control / Set-Cookie (rare) |
Resources that don’t use HttpClient v22
From v22, resource() and rxResource() accept an id. The value resolved on the server is written into TransferState under that id, and in the browser the resource starts directly in the resolved state, without calling the loader again — again only while the hydration cache is active, that is until the first stability, and only with provideClientHydration() in the configuration.
import { Component, inject, resource } from '@angular/core';
@Component({ selector: 'app-weather', template: `{{ forecast.value()?.summary }}` })
export class Weather {
private readonly sdk = inject(WeatherSdk); // not HttpClient → the HTTP cache doesn't see it
// v22: with an id, the value resolved on the server is put in TransferState and reused during hydration.
// The id must be unique on the page and identical on the server and in the browser.
protected readonly forecast = resource({
id: 'weather-bucharest',
loader: () => this.sdk.forecast('Bucharest'),
});
}Different origins on the server and in the browser
If the server talks to the API on an internal address and the browser on the public one, the URLs differ and the cache keys no longer match. HTTP_TRANSFER_CACHE_ORIGIN_MAP (from @angular/common/http) tells the server to compute the key as if it had used the public origin. It is declared only in the server configuration: if Angular finds it in the browser, it throws the error NG02803.
// api-base-url.ts — in the browser: the public origin
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
factory: () => 'https://api.magazin.ro',
});
// app.config.server.ts (fragment) — on the server: the internal network, without going through the load balancer
providers: [
provideServerRendering(withRoutes(serverRoutes)),
{ provide: API_BASE_URL, useValue: 'http://catalog.internal:8080' },
// the same paths on both origins → the keys in the transfer cache match
// (only in the server configuration; in the browser Angular throws an error if it finds it)
{
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
useValue: { 'http://catalog.internal:8080': 'https://api.magazin.ro' },
},
];One more detail from v22, when HttpClient uses the fetch backend by default: on the server, each response body is limited to 1 MB (otherwise the error NG02825). The limit is changed with provideServerRendering({ maxResponseBodySize }, withRoutes(…)), but the guide recommends keeping it small: large downloads have no place in server rendering.
Stability and PendingTasks
The server doesn’t know when your page is “ready”. Its rule is simple: after bootstrap it waits for ApplicationRef.whenStable() and only then serialises the DOM and TransferState. The application is stable when there are no more tasks in PendingTasks. Angular adds tasks by itself for the HttpClient requests in progress, for router navigation and for the loading resources (resource, httpResource). With Zone.js, any timer or microtask in the Angular zone also keeps the application unstable.
Zoneless applications — the default since v21 — no longer see your timers and promises. If you load data with your own SDK, with fetch or after a setTimeout, the server serialises before the data arrives: the HTML leaves empty, and the browser asks for it again. Make them visible with PendingTasks (stable since v20; the run method and the pendingUntilEvent operator are still developer preview):
import { Component, PendingTasks, inject, signal } from '@angular/core';
import { pendingUntilEvent, toSignal } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-price', template: `{{ price() }} · {{ stock() }}` })
export class Price {
private readonly tasks = inject(PendingTasks);
private readonly sdk = inject(PricingSdk); // its own promises, not HttpClient
readonly price = signal<string | null>(null);
// 1. run(): stability waits until the async function finishes (developer preview)
constructor() {
this.tasks.run(async () => this.price.set(await this.sdk.priceOf(3)));
}
// 2. add(): manual control — DON'T forget the cleanup, otherwise the server never stops waiting
async refresh(): Promise<void> {
const done = this.tasks.add();
try {
this.price.set(await this.sdk.priceOf(3));
} finally {
done();
}
}
// 3. pendingUntilEvent(): unstable until the first emission / complete / error (developer preview)
readonly stock = toSignal(this.sdk.stock$(3).pipe(pendingUntilEvent()), { initialValue: 0 });
}whenStable()): <p class="price"></p>
PendingTasks is never removed (an add() without a finally, an Observable that never emits with pendingUntilEvent), the response hangs until the proxy cuts it off. With Zone.js, a setInterval started at initialisation does the same. Polling, websockets and recurring timers have no place on the server: start them in afterNextRender.import { provideStabilityDebugging } from '@angular/core';
// Active by default in dev mode when you use provideClientHydration().
// Manually: for SSR without hydration or in a production build, temporarily.
bootstrapApplication(App, {
providers: [provideStabilityDebugging()],
});
// If the application doesn't become stable within ~9 s, it logs to the console the PendingTasks
// that are keeping it busy. With Zone.js, also import 'zone.js/plugins/task-tracking' for stack traces.Request, response and the 404 status
@angular/core exports three tokens for the current request. @angular/ssr fills them only during rendering on request (RenderMode.Server); in the browser, at prerender, at build and when extracting the routes in dev they are null — so always use ?..
| Token | Contains | Used for |
|---|---|---|
REQUEST | the Request from the Web API: url, headers | cookies, Accept-Language, an absolute URL for canonical / Open Graph |
RESPONSE_INIT | a mutable ResponseInit: status, statusText, headers | dynamic 404 / 410, headers computed at rendering |
REQUEST_CONTEXT | unknown: the second argument of handle(request, context) | tenant, request id, the user already authenticated by a middleware |
import { Component, RESPONSE_INIT, inject } from '@angular/core';
@Component({
selector: 'app-product-missing',
template: `<h1>This product no longer exists</h1><a href="/produse">See the catalog</a>`,
})
export class ProductMissing {
constructor() {
// null in the browser, during prerender (SSG) and at build time — that's why the ?.
const response = inject(RESPONSE_INIT);
if (response) response.status = 404; // otherwise: a "soft 404" gets indexed as a real page
}
}Why the status matters: a “Product doesn’t exist” page served with 200 is a soft 404, which search engines can index as a real page. The status of the ServerRoute covers the static cases (the ** route); for “the id doesn’t exist in the database” you need RESPONSE_INIT. It works because @angular/ssr builds the response only after whenStable(), so until then you can change the status.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, REQUEST, Service, inject } from '@angular/core';
@Service()
export class RequestInfo {
private readonly request = inject(REQUEST); // Request from the Web API; null in the browser / SSG
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
/** Preferred language: the header on the server, navigator in the browser. */
preferredLanguage(): string {
if (this.request) return this.request.headers.get('accept-language')?.split(',')[0].trim() || 'en';
// no request: browser OR prerender. DON'T test "typeof navigator" — Node 21+ has it.
return this.isBrowser ? navigator.language : 'en';
}
cookie(name: string): string | null {
const raw = this.request?.headers.get('cookie') ?? '';
const match = raw.split('; ').find((c) => c.startsWith(`${name}=`));
return match ? decodeURIComponent(match.slice(name.length + 1)) : null;
}
}Relative and absolute URLs
On the server there is no “current page” against which a relative URL can be resolved. HttpClient solves the problem by itself: @angular/platform-server adds an interceptor that turns /api/… into an absolute URL, based on the request URL and <base href>. A direct fetch() and the SDKs that use it don’t benefit from this.
// ✓ HttpClient on the server: the relative URL is resolved against the URL of the current request
this.http.get('/api/products'); // → https://magazin.ro/api/products
// ✗ fetch() directly (or an SDK that uses it) — Node has no "current page":
await fetch('/api/products'); // TypeError: Failed to parse URL from /api/products
// ✓ if you must use fetch(): build the absolute URL from the request
const base = inject(REQUEST)?.url ?? inject(DOCUMENT).location.href;
await fetch(new URL('/api/products', base));Precisely because the URLs are built from the request’s host, AngularNodeAppEngine checks the hostname against allowedHosts (in angular.json, at build.options.security.allowedHosts, or through the NG_ALLOWED_HOSTS variable) and answers 400 for an unknown host. It is the protection against SSRF: configure it with your domains.
State leaking between requests
In the browser, a JavaScript module is evaluated once per tab, that is per user. On the server, the same module is evaluated once per process and serves thousands of requests. Any module-level variable becomes state shared by all users. A provider with useValue has the same problem: the object is created when the module loads and lives until the server restarts — the SSR guide says so explicitly and recommends useFactory.
Services are safe: on each request, @angular/ssr creates a new platform and a new application, so a new root injector. Each user gets their own instances, destroyed after the response.
// ✗ Module-level state: the file is evaluated ONCE per Node process,
// so the variables are shared by ALL requests (and all users).
let currentUser: User | null = null; // user B sees user A
const cart = new Map<number, number>(); // the cart grows with each request
// ✗ The same with useValue: the object is created once, when the module loads
{ provide: CART_STATE, useValue: { items: [] } }
// ✓ A service: on the server each request bootstraps a new application,
// so it gets a new instance (which is destroyed after the response).
@Service()
export class CartStore {
private readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length);
}
// ✓ Or useFactory: runs for each application that is created
{ provide: CART_STATE, useFactory: () => ({ items: [] }) }const cache = new Map() at module level looks like an optimisation. On the server it is a memory leak (it grows endlessly) and, if the key doesn’t include the user, a data leak between accounts. If you want a cache between requests, make it explicit — in server.ts or at the CDN — with keys, a TTL and a size limit.Case study: the theme
The theme is the classic example: the preference lives in localStorage, and the CSS depends on an attribute on <html>. This site is an SPA, without SSR, so its ThemeStore is correct here. But it is a good exercise: if we started SSR tomorrow, what would break?
// This site's ThemeStore (fragment). The site is an SPA, so it's correct HERE.
@Service()
export class ThemeStore {
private readonly document = inject(DOCUMENT);
// ✗ on the server: defaultView is the Domino window, which has NO matchMedia → TypeError
private readonly media = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');
// readStoredMode() reads localStorage in a try/catch: on the server it doesn't crash,
// but it always returns 'system' — the server can't know what the user chose.
readonly mode = signal<ThemeMode>(readStoredMode());
constructor() {
this.media?.addEventListener('change', (e) => this.systemDark.set(e.matches));
effect(() => {
// ✓ DOM through DOCUMENT: works on the server too
// localStorage in try/catch: on the server it is silently ignored
});
}
}Two problems. The first: the line with matchMedia throws when the service is created, and ?. doesn’t help, because on the server defaultView exists (the defaultView?.matchMedia(…) fragment in the first demo reproduces exactly this). The second: the server always renders the system theme, because it can’t see localStorage. The SSR-safe variant moves the preference into a cookie, which the server reads from REQUEST, and the effect() writes the attribute on the server too:
import { isPlatformBrowser } from '@angular/common';
import {
DOCUMENT, DestroyRef, PLATFORM_ID, REQUEST, Service,
afterNextRender, computed, effect, inject, signal,
} from '@angular/core';
export type ThemeMode = 'system' | 'light' | 'dark';
const COOKIE = 'atlas-theme';
@Service()
export class ThemeStore {
private readonly document = inject(DOCUMENT);
private readonly request = inject(REQUEST);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
// The server doesn't know whether the system is dark: for 'system' the CSS decides
// (prefers-color-scheme), and the signal is corrected in the browser.
private readonly systemDark = signal(false);
readonly mode = signal<ThemeMode>(this.readCookie());
readonly isDark = computed(
() => this.mode() === 'dark' || (this.mode() === 'system' && this.systemDark()),
);
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const media = matchMedia('(prefers-color-scheme: dark)'); // browser only
const onChange = (e: MediaQueryListEvent) => this.systemDark.set(e.matches);
this.systemDark.set(media.matches);
media.addEventListener('change', onChange);
destroyRef.onDestroy(() => media.removeEventListener('change', onChange));
});
effect(() => {
const mode = this.mode();
// also runs on the server → <html data-theme="dark"> is already in the HTML, no flash
const root = this.document.documentElement;
if (mode === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', mode);
// cookie (not localStorage): the server receives it on the next request
if (this.isBrowser) {
this.document.cookie = `${COOKIE}=${mode}; path=/; max-age=31536000; samesite=lax`;
}
});
}
private readCookie(): ThemeMode {
const raw = this.request?.headers.get('cookie') // server: the header of the request
?? (this.isBrowser ? this.document.cookie : ''); // browser (on the server, cookie throws)
const value = raw
.split('; ')
.find((c) => c.startsWith(`${COOKIE}=`))
?.slice(COOKIE.length + 1);
return value === 'light' || value === 'dark' ? value : 'system';
}
}The alternative without a cookie: you keep localStorage and an inline script in index.html that sets data-theme before the first paint — exactly what this site does. It works with SSR too (the server sends HTML without the attribute, the script puts it in place in the browser before the application), but the server still doesn’t know the theme, so it can’t choose, for example, the right images or theme-color.
<!-- The alternative without a cookie (what this site does): an inline script in <head>,
which runs before the first paint, in any render mode (CSR, SSR, prerender). -->
<script>
try {
var t = localStorage.getItem('atlas-theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>When YES, when NO
SSR-safe code doesn’t mean if (isBrowser) everywhere, but each thing placed in the right spot.
afterNextRender/afterRenderEffectfor everything that touches the DOM, the layout, the storage or a browser library.inject(DOCUMENT),Title,Metafor everything that must be in the HTML from the server (SEO, sharing).- Platform providers in
app.config.server.tswhen the server and the browser do different things. - The default HTTP cache for public GETs; a manual
TransferStateorresource({ id })for data that doesn’t go throughHttpClient. PendingTasksorresource()for any async work whose result must be in the HTML.RESPONSE_INITfor dynamic statuses,REQUESTfor cookies and language.
isPlatformBrowserin an@ifin the template →@deferorafterNextRender, with the same HTML on both platforms.typeof windowsprinkled through components → a service with a separate server implementation.TransferStatefor large lists or per-user data on pages cached at the CDN → load them in the browser.PendingTasksfor polling, websockets, analytics → start them inafterNextRender; the server shouldn’t wait for them. A complete example is on the WebSockets page.- HTTP cache for sensitive responses →
filterortransferCache: false. ngSkipHydrationas a permanent solution → fix the direct DOM manipulation.
Common pitfalls
effect that writes to localStorage, calls matchMedia or starts an observer breaks at rendering. What belongs to the browser goes into afterRenderEffect or under afterNextRender.document.defaultView?.matchMedia(…) and typeof navigator !== 'undefined' look safe, but on the server defaultView exists (the Domino window, without matchMedia), and Node 21+ has a navigator. The first throws a TypeError, the second gives the wrong answer. Use afterNextRender or isPlatformBrowser.- Measurements in the constructor or
ngOnInit: they break on the server (there is no layout). In the browser, the constructor measures 0, and inngOnInitthe bindings and the@if/@forblocks of the component aren’t rendered yet. Fix:afterNextRender({ read }). - Timers and subscriptions started in the constructor: with Zone.js they block serialisation, zoneless ones keep running. Fix: start them in
afterNextRenderand stop them withDestroyRef. pendingTasks.add()withoutfinally: an exception and the request never finish. Fix:try/finallyorrun().- Untracked async in zoneless:
fetch, SDKs,setTimeout→ empty HTML + one more request in the browser. Fix:resource()orPendingTasks. - Generic
TransferStatekeys ('data') or a staticresource({ id })in a component used twice on the page: the values overwrite each other. Fix: keys that include the parameters (article:${slug}), unique ids per page. - The key isn’t removed after reading: a later navigation on the same page gets the old data from the HTML. Fix:
state.remove(key)after the first use. HTTP_TRANSFER_CACHE_ORIGIN_MAPinapp.config.ts: error NG02803 in the browser. Fix: only inapp.config.server.ts.- A relative
fetch('/api/…')on the server:TypeError: Failed to parse URL. Fix:HttpClientor an absolute URL built fromREQUEST. - A “not found” page with status 200: a soft 404 in Google. Fix:
RESPONSE_INITorstatus: 404on the**route. - Values that differ between server and browser:
Date.now(),Math.random(), dates formatted in the server’s time zone (often UTC). The text changes at hydration. Fix: generate them once on the server and transfer them, or display them only in the browser. - Module-level state and
useValuewith objects: shared between users. Fix: services oruseFactory.
Cheat sheet
| You want to… | Use |
|---|---|
| touch the DOM / the layout once, after rendering | afterNextRender({ earlyRead, write, read }) |
| synchronise the DOM with some signals | afterRenderEffect(() => …) |
| measure after every render | afterEveryRender({ read }) |
load a library that touches window | await import('lib') in afterNextRender, or @defer |
read / write title, meta, canonical | inject(DOCUMENT), Title, Meta |
| a different implementation on the server | an abstract class + a provider in app.config.server.ts (mergeApplicationConfig) |
| a side effect only in the browser | isPlatformBrowser(inject(PLATFORM_ID)) |
| not to repeat the GETs in the browser | provideClientHydration() (default), withHttpTransferCacheOptions(…) |
| take a request out of the cache | filter or { transferCache: false } |
transfer data that doesn’t come through HttpClient | makeStateKey<T>() + TransferState, or resource({ id }) (v22) |
| different API origins on the server and in the browser | HTTP_TRANSFER_CACHE_ORIGIN_MAP, only on the server |
| the server to wait for an async | resource(), PendingTasks.run() / add(), pendingUntilEvent() |
| find out why the application doesn’t become stable | provideStabilityDebugging() |
| read cookies / headers | inject(REQUEST)?.headers.get('cookie') |
| answer with a 404 | inject(RESPONSE_INIT) → status = 404, or status: 404 on the ** route |
receive context from server.ts | handle(req, ctx) → inject(REQUEST_CONTEXT) |
| per-user state on the server | services or useFactory — never module variables |