skip to content

In a zoneless Angular SSR app, why would you use PendingTasks, and how do its add() and run() methods hold off serialization?

level: seniorimportance: nice to knowfreq 27%

answer

  1. the zone no longer counts tasks
  2. stability decides when HTML is sent
  3. add() returns a cleanup function
  4. pendingUntilEvent for observables

basics

~20 s

Without zone.js, Angular cannot see arbitrary async work, so SSR might serialize before data arrives. PendingTasks registers work that must finish first: add() returns a cleanup function to call when done, and run() wraps an async function and removes the task when it settles.

solid answer

~50 s

Server rendering waits for the application to become **stable** before serializing HTML. With zone.js, stability meant "no pending zone tasks", so any timer or promise delayed it. In a zoneless app Angular only knows about work registered as a **pending task**; the router and `HttpClient` register theirs, but a custom `await` (a third-party SDK, a raw `fetch`) is invisible and the page can be serialized without its data. `PendingTasks` fixes that: `const done = inject(PendingTasks).add()` holds stability open until you call `done()` (in a `finally`), and `run(async () => ...)` does the same around an async function, removing the task when its promise settles and reporting errors to the error handler. For observables, `pendingUntilEvent()` from `@angular/core/rxjs-interop` keeps the app unstable until the stream emits, completes, errors or is unsubscribed. `PendingTasks` is stable since v20; `run()` and `pendingUntilEvent()` are still developer preview.

code

ts · 17 lines
ts
import {Injectable, PendingTasks, inject, signal} from '@angular/core';

interface RatesSdk { load(): Promise<Record<string, number>>; }
declare const ratesSdk: RatesSdk;

@Injectable({providedIn: 'root'})
export class ExchangeRates {
  private readonly tasks = inject(PendingTasks);
  readonly rates = signal<Record<string, number>>({});

  load(): void {
    // SSR serialization waits until this promise settles.
    this.tasks.run(async () => {
      this.rates.set(await ratesSdk.load());
    });
  }
}

go deeper

for a junior

Know that server rendering waits for the app to become stable and that zoneless apps must register some async work explicitly.

for a middle

Explain stability, which work Angular tracks for you, and the add(), run() and pendingUntilEvent() forms.

for a senior

Identify the async boundaries that SSR cannot see in a zoneless app, register them with a finally-safe cleanup, and avoid tasks that never end.

for a principal

Set a rule for which data must be server-rendered versus loaded client-side, since every pending task directly lengthens server response time.

## Stability decides when the server responds With **server-side rendering (SSR)**, Angular renders the application on the server and then **serializes** the resulting DOM to HTML. It must not do that too early, or the HTML goes out with loading spinners instead of data. The point at which it is safe is called **application stability**: the moment no tracked asynchronous work is pending. `ApplicationRef.isStable` and `ApplicationRef.whenStable()` expose it. ## How zone.js hid the problem In a zone-based app, stability was effectively "the zone has no pending tasks". Every timer, promise and XHR started inside the Angular zone delayed stability automatically, so an app could `await` a third-party SDK in a service and SSR would still wait for it. Nobody registered anything. ## What changes zoneless A **zoneless** app has no zone counting tasks. Angular derives stability from its own **pending tasks** instead. The framework registers some itself: the zoneless guide names an ongoing router navigation and an incomplete `HttpClient` request, and says the list is not exhaustive. Work Angular does not know about is invisible: - an `await` on a vendor SDK or a raw `fetch()`; - a `setTimeout` that populates state after a delay; - a subscription to an observable that does not come from `HttpClient`. If that work sets state the page needs, the server can serialize before it finishes. ## The PendingTasks API `PendingTasks` is an injectable service provided in root. | Member | What it does | |---|---| | `add()` | Registers a task and returns a cleanup function; stability is held open until that function is called | | `run(fn)` | Registers a task, calls the async `fn`, reports a rejection to the error handler and removes the task when the promise settles | | `pendingUntilEvent()` (rxjs-interop) | Operator that holds a task from subscription until the first emission, completion, error or unsubscription | The manual form needs a `finally`, or a failure leaves the application permanently unstable and the server request hangs: ```ts const tasks = inject(PendingTasks); const done = tasks.add(); try { this.rates.set(await ratesSdk.load()); } finally { done(); } ``` Calling the cleanup twice is harmless; the second call is ignored. Removing a task notifies the scheduler, so stability is reported only after the next change detection has rendered the new state. ## Where it matters, and where it does not 1. **SSR**: the main reason; serialization waits until all pending tasks are removed. 2. **Tests**: `fixture.whenStable()` and `ApplicationRef.whenStable()` wait for the same tasks, so registered work is awaited in tests as well. 3. **Plain client-side rendering**: no serialization happens, so a missing task does not break the page; it only affects code that waits for stability. Use it only for work the rendered page depends on. Registering a long-poll or a WebSocket as a pending task would make the application **never** stable, and the server would never respond. Prefer `HttpClient` or `httpResource` for HTTP, since they are already tracked, and reach for `PendingTasks` at the boundary with code Angular cannot see. ## Diagnosing a page served without its data The typical report is "the server-rendered HTML shows an empty panel, then the data pops in after hydration". To confirm it is a stability problem rather than a rendering bug: 1. Fetch the page with JavaScript disabled, or view the raw response, and check whether the data is missing from the HTML. 2. Find where that data is loaded and check whether it goes through `HttpClient` (tracked) or through something Angular cannot see (untracked). 3. Wrap the untracked call in `PendingTasks.run()`, or add `pendingUntilEvent()` to the observable, and render again. If the response now includes the data and the server response time rises by roughly the duration of that call, the task is doing its job. ## API status `PendingTasks` and `add()` are public API since v20. `run()` has been in developer preview since v19, and `pendingUntilEvent()` has been in developer preview since v20, so their shape may still change between majors.

  • What happens if an Angular SSR app adds a pending task and never removes it?
    The application never becomes stable, so server rendering keeps waiting for stability and the request stalls instead of returning HTML. That is why the manual `add()` form belongs in a `try/finally`, and why long-lived streams such as WebSockets must not be registered as pending tasks.
  • Does an Angular zoneless SSR app need PendingTasks around HttpClient calls?
    No. The zoneless guide says the framework uses PendingTasks internally for an incomplete HttpClient request and an ongoing router navigation, so those already delay serialization. PendingTasks is for async work Angular cannot see, such as a vendor SDK promise or a non-HTTP observable.

saying these in an interview costs you the question

  • Zoneless SSR waits for every promise, just like zone.js did.
  • HttpClient requests must be wrapped in PendingTasks.run().
  • Forgetting to call the add() cleanup only delays rendering slightly.
  • PendingTasks marks the calling component dirty so its view refreshes.
  • Register a WebSocket as a pending task so SSR includes its data.