Everything your application does (JavaScript, change detection, layout, painting) runs on a single thread: the main thread. For a smooth animation at 60 frames per second, each frame has roughly 16 ms. A calculation that takes 800 ms means 50 frames lost: buttons don’t respond, the input doesn’t receive keystrokes, animations freeze.
A Web Worker runs JavaScript code on another thread, in parallel. The main thread stays free for the interface, and the two communicate through messages. The Angular CLI builds a separate bundle for the worker, written in TypeScript and with ordinary imports.
@defer) don’t help when the problem is a long calculation. The application is slow because the main thread is busy, not because it renders too much. Typical examples: parsing a large CSV, filtering hundreds of thousands of rows, generating a PDF, processing images, encryption.The main thread, live
The demo runs the same prime-counting code in two places. The dot is moved from JavaScript, with requestAnimationFrame, so it stops exactly when the main thread is blocked. A CSS animation could have kept running on the compositor and hidden the problem. “Longest frame” shows how long the interface was frozen.
How to create a worker
ng generate web-worker configures the project and creates the skeleton. In practice, everything comes down to new Worker(new URL('./x.worker', import.meta.url), { type: 'module' }). The form new URL(..., import.meta.url) is the one the builder recognizes: it treats it as a separate entry point and gives it its own bundle. This is exactly how the demo above is built, without any other configuration. In the worker file, /// <reference lib="webworker" /> brings in the right types (postMessage, self).
ng generate web-worker app/reports/report
# configures the project for workers (if it isn't already) and creates report.worker.tsWhat a worker can and cannot do
| Can | Cannot |
|---|---|
| pure computation, TypeScript code imported normally | access the DOM (document, elements) |
fetch, IndexedDB, timers, WebAssembly | use Angular DI, the application’s components or services |
receive and send data through postMessage | read the application’s state: it only receives what you send it |
The worker hidden inside a service
Components shouldn’t know about postMessage. A service does the connecting: it sends the request with an id, matches the response and returns a Promise. This integrates directly with resource() (see resource), and the result becomes a signal with isLoading and error. On the server (SSR) and in some test environments there is no Worker; the Angular documentation explicitly mentions @angular/platform-server. Provide a synchronous fallback (see Server-compatible code).
import { DestroyRef, Injectable, inject } from '@angular/core';
/** Hides the worker's details: the rest of the application sees an async function. */
@Injectable({ providedIn: 'root' })
export class ReportWorker {
private worker?: Worker;
private nextId = 0;
private readonly pending = new Map<number, (r: Report) => void>();
constructor() {
inject(DestroyRef).onDestroy(() => this.worker?.terminate());
}
build(input: ReportInput): Promise<Report> {
if (typeof Worker === 'undefined') return Promise.resolve(buildReport(input)); // SSR / tests: fallback
this.worker ??= this.create();
const id = ++this.nextId;
return new Promise((resolve) => {
this.pending.set(id, resolve);
this.worker!.postMessage({ id, input });
});
}
private create(): Worker {
const w = new Worker(new URL('./report.worker', import.meta.url), { type: 'module' });
w.onmessage = ({ data }) => {
this.pending.get(data.id)?.(data.report);
this.pending.delete(data.id);
};
return w;
}
}Copy vs transfer
Messages are copied through structured clone: objects, arrays, Map, Date and buffers are duplicated in the memory of the other thread. For large data, copying can cost more than the calculation. An ArrayBuffer can instead be transferred: ownership passes to the worker instantly, and your buffer becomes empty (byteLength === 0).
const pixels = new Uint8ClampedArray(1920 * 1080 * 4); // ~8 MB
// Copy (structured clone): the browser duplicates the data — the cost is proportional to its size
worker.postMessage(pixels.buffer);
// Transfer: ownership passes to the worker, without copying; your buffer becomes “detached”
worker.postMessage(pixels.buffer, [pixels.buffer]);
console.log(pixels.buffer.byteLength); // 0| Mode | Round trip | buffer.byteLength after sending |
|---|---|---|
| Send a buffer in both modes and compare. | ||
When YES, when NO
- Continuous calculations longer than ~50 ms: parsing large CSV/JSON, aggregations, filtering and sorting hundreds of thousands of rows.
- Processing images, audio, compression, encryption, hashing.
- Heavy libraries that don’t touch the DOM (text diffs, complex validations, WebAssembly).
- Polling or calculations that would compete with animations and input.
- Calculations of a few milliseconds: messaging and starting the worker cost more.
- Working with the DOM or rendering: the worker has no access. Optimize rendering (see virtual scroll, performance).
- Huge data that goes back and forth through copying. The cloning cost cancels the gain; use transfer or process on the server.
- Ordinary HTTP requests: they are already asynchronous and don’t block the thread.
Common pitfalls
- A worker created on every click and never finished. Each one has its own thread and its own memory. Reuse it and call
terminate()on destroy. - No
onerror: an exception in the worker doesn’t reach the application’s ErrorHandler. Handle errors explicitly and send them back as a message. - Class instances sent in messages: structured clone keeps the data, not the prototype. A
class Moneyarrives as a plain object, without methods. - CSS animations as “proof” that the UI works: they can run on the compositor even when JavaScript is blocked. Test with an input.
- No SSR fallback:
new Workerthrows an error on the server.
Cheat sheet
CREATE new Worker(new URL('./x.worker', import.meta.url), { type: 'module' })
ng generate web-worker <path> — configures the project + scaffold
IN WORKER /// <reference lib="webworker" /> · addEventListener('message', ...) · postMessage(...)
no DOM, no Angular DI, no window — only computation and fetch
MESSAGES structured clone (copy) — simple data, Map, Set, Date, ArrayBuffer...
transfer: postMessage(buf, [buf]) — instant, your buffer becomes empty
IN THE APP service that returns a Promise/Observable → resource() / toSignal()
terminate() at the end; onerror for errors
SSR typeof Worker === 'undefined' on the server → synchronous fallback
WORTH IT > ~50 ms of continuous computation: parsing, compression, encryption, filtering big data, images
NOT WORTH IT small computations, DOM work, huge data copied back and forth without transfer