In Angular SSR, why can a page be sent without data loaded by a third-party SDK promise, or never finish rendering, and how does PendingTasks help?
answer
- serialize when the app is stable
- what stability actually tracks
- untracked promise, early HTML
- never-removed task, hung request
basics
~20 sThe server sends HTML once the app is stable, with no pending tasks left. A zoneless app ignores untracked work like an SDK promise, so HTML ships early; a task never released hangs the render. PendingTasks registers such work.
solid answer
~40 sAngular's server render waits for `ApplicationRef.whenStable()` and then serializes the DOM and `TransferState`. Stability means no pending tasks: `HttpClient` requests, router navigations and resource loads register them automatically, and zone-based apps also count Zone.js macrotasks. In a zoneless app, the default since v21, a promise from a third-party SDK or a `setTimeout` is invisible, so the app becomes stable and the HTML is sent before the data arrives. Wrap such work with `inject(PendingTasks).add()` and call the returned cleanup in `finally`, use `pendingTasks.run(async () => ...)`, or pipe observables through `pendingUntilEvent()`. The opposite failure: a task that is never cleaned up, or in a zone-based app a `setInterval` started at bootstrap, keeps the app unstable forever and the server response never completes. Start polling only in the browser.
go deeper
Know that the server sends HTML only once the app is stable, and that HttpClient requests are waited for automatically.
Explain what counts as a pending task, and how PendingTasks.add, run and pendingUntilEvent register work Angular cannot see.
Diagnose both failures: early HTML from untracked work and hung renders from tasks never released, and note how zoneless changes which one you hit.
Set rules for async work in server-rendered code: tracked, bounded by timeouts, and with long-lived work kept to the browser.
## When the server decides the page is done A server render has no natural end: components can start asynchronous work at any time. Angular's rule is that the server waits for the application to become **stable**, via `ApplicationRef.whenStable()`, and only then serializes the DOM and `TransferState` into the HTML response. **Stable** means there are no pending tasks left. What counts as a pending task: - `HttpClient` requests, which Angular tracks automatically; - router navigations; - `resource()` and `httpResource()` loads; - anything registered through the `PendingTasks` service or the `pendingUntilEvent()` operator; - in a zone-based app, Zone.js macrotasks such as timers, on top of the above. ## Failure 1: HTML sent before the data In a **zoneless** app (the default for new apps since v21), Angular does not see arbitrary promises or timers. Suppose a component loads article recommendations through a third-party SDK that uses its own `fetch` and returns a promise: 1. The route renders and starts the SDK call. 2. No tracked task is pending, so the app is stable at once. 3. The server serializes the HTML with an empty recommendations section. 4. The browser boots, calls the SDK again, and fills the section. The user sees it pop in, and the server did the work for nothing. The fix is to tell Angular about the work: ```ts import { Injectable, PendingTasks, inject, signal } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class Recommendations { private pendingTasks = inject(PendingTasks); items = signal<string[]>([]); async load(sdkCall: () => Promise<string[]>) { const done = this.pendingTasks.add(); try { this.items.set(await sdkCall()); } finally { done(); // always release, or the server render never completes } } } ``` Alternatives: `pendingTasks.run(async () => { ... })` wraps the same add-and-release (the source still marks `run()` as developer preview), and `pendingUntilEvent()` from `@angular/core/rxjs-interop` keeps the app unstable until an observable emits, completes, errors or is unsubscribed. To avoid a second fetch in the browser, also carry the value across with `TransferState`, because the SDK's calls do not go through the HTTP transfer cache. ## Failure 2: the render never finishes The opposite mistake keeps the app unstable forever. The server keeps waiting and the request hangs until a proxy or the client gives up: | Cause | Why it blocks | |---|---| | `PendingTasks.add()` without calling the cleanup on an error path | The task is never removed | | In a zone-based app, `setInterval` or RxJS `interval` started at bootstrap | Zone.js always sees a pending macrotask | | A `pendingUntilEvent()` observable that never emits or completes | The task stays open | | A long-polling or streaming connection opened during the render | The work has no end | The fixes: release tasks in `finally`, start polling and streaming only in the browser (in `afterNextRender()` or behind a platform check), and add a timeout to external calls made during the render. ## Zone-based versus zoneless, in short | | Zone-based app | Zoneless app | |---|---|---| | Untracked promise or timer | Holds stability (Zone.js sees it) | Ignored | | `setInterval` at bootstrap | Never stable | No effect on stability | | What you must do | Avoid long-lived timers on the server | Register async render work with `PendingTasks` | Migrating from Zone.js to zoneless can therefore turn "the server hangs on a timer" into "the server ships HTML without data", with no code change in the component. ## Keeping render-time work bounded Registering work with `PendingTasks` makes the server wait for it, which is only safe if the work ends. A slow third-party call now delays every server-rendered response on that route: - Put a timeout on external calls made during the render, and release the task when the timeout fires. - Render a fallback on timeout, and let the browser fill the section after hydration. - Watch server render durations per route; a jump often means a newly registered task is waiting on something slow. ## Diagnosing it 1. Compare the server HTML (page source) with the hydrated page: missing sections point to untracked work. 2. If requests hang, look for intervals, open streams or `add()` calls without cleanup started during the render. 3. In a test, `await fixture.whenStable()` exercises the same notion of stability.
- Why does HttpClient data appear in the server HTML without any PendingTasks code?HttpClient registers each request as a pending task and releases it when the response arrives, so the server waits for it automatically. The same holds for router navigations and resource loads. Only work Angular does not know about, such as an SDK's own promise, needs explicit registration.
- How do you keep a live-score poller from blocking the server render?Start it only in the browser, for example inside `afterNextRender()`, or behind `isPlatformBrowser()`. On the server, render the initial score from a normal tracked request, and let the browser take over polling after hydration.
Stability works like a restaurant that closes only when every order ticket on the rail is cleared: an order taken without a ticket is forgotten at closing time, and a ticket nobody ever clears keeps the doors open all night.
saying these in an interview costs you the question
- The server waits for every promise automatically, zoneless or not.
- HttpClient requests need PendingTasks wrappers to be awaited on the server.
- A setInterval started at bootstrap is harmless in every SSR app.
- Forgetting the PendingTasks cleanup only wastes a little memory.
- Stability is reached as soon as the first change detection finishes.