Mert Özen Further With Every Line
Frontend Browser APIs and PWA — #08

Using Web Workers in Angular: ng generate web-worker, Comlink and Relieving the Main Thread

Mert Özen Nov 15, 2026 14 min 10 views
Using Web Workers in Angular: ng generate web-worker, Comlink and Relieving the Main Thread

Putting a heavy calculation into an async method does not take it off the main thread; in Angular you move the work into a worker generated with ng generate web-worker and wrap it in a service with Comlink, or split it with scheduler.yield(). For a 5,000-book client-side search with the CPU 4 times slower we measure the slowest interaction at 408 ms on the main thread, 56 ms with yield and 40 ms in the worker, and cover the tsconfig trap, cancellation and what to move to a worker.

Version note: The examples in this post were run with Angular 22.2 (TypeScript 6.0), Angular CLI 22.2.7, Comlink 4.4.2, Node.js 24 and Playwright 1.65; measurements were taken in Playwright's Chromium 158 and WebKit 27.2 builds, on a production build. Support data was read from MDN browser-compat-data 8.1.9 and web-features 3.46; Firefox could not be run in this environment.

Predict First

A search page is being added to Bookstore, the online bookstore used as the shared example across this site's series. The title, author and subject data of the 5,000-book catalog is downloaded to the browser once, and search runs on the client, tolerating typos: a user who types "midlemarch eliot" finds Middlemarch. The search code runs on every key press. On the developer's desktop nothing feels slow. A teammate says "we already return it from an async method, it won't block the main thread". What happens on a mid-range phone, and does writing async change anything?

In this post we run this search in three ways and measure it: in one piece on the main thread, split into slices with scheduler.yield(), and in a Web Worker generated by the Angular CLI, with Comlink. We covered the worker basics (postMessage, structured clone, Transferable objects, module workers) in the Advanced JavaScript series post "JavaScript Web Workers: postMessage, Transferable Objects and Module Workers"; the focus here is the setup in Angular, the measurement and the decision.

A Client-Side Search Index

The search code consists of pure functions; the same file can run both on the main thread and in the worker. Each book's title, author and subject words are normalized and indexed once; every search term must match the start of a word with 0, 1 or 2 typos depending on its length:

export interface IndexedEntry {
  entry: CatalogEntry;
  words: string[];
}

const normalize = (text: string) =>
  text
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '')
    .toLowerCase();

export function buildIndex(entries: CatalogEntry[]): IndexedEntry[] {
  return entries.map((entry) => ({
    entry,
    words: normalize(`${entry.title} ${entry.author} ${entry.subjects}`)
      .split(/[^\p{L}\p{N}]+/u)
      .filter(Boolean),
  }));
}

export function terms(query: string): string[] {
  return normalize(query).split(/\s+/).filter(Boolean);
}

// every term must match the start of some word with at most 0, 1 or 2 typos, depending on its length
export function searchSlice(items: IndexedEntry[], queryTerms: string[]): SearchHit[] {
  const hits: SearchHit[] = [];
  for (const { entry, words } of items) {
    let typos = 0;
    for (const term of queryTerms) {
      let best = Infinity;
      for (const word of words) {
        for (let n = term.length - 1; n <= term.length + 1; n++) {
          best = Math.min(best, distance(term, word.slice(0, n)));
        }
      }
      if (best > allowedTypos(term)) {
        typos = -1;
        break;
      }
      typos += best;
    }
    if (typos >= 0)
      hits.push({ isbn: entry.isbn, title: entry.title, author: entry.author, typos });
  }
  return hits;
}

The calculation looks cheap, but the multipliers are large: 5,000 books, around ten words per book, three prefix lengths per word and a Levenshtein distance for each. In Node.js 24 a single search took about 70 ms. The mock API serves the catalog at /api/catalog/index (672 KB as JSON).

What Does ng generate web-worker Produce?

npx ng generate web-worker search/catalog-search
CREATE src/app/search/catalog-search.worker.ts (157 bytes)
CREATE tsconfig.worker.json (334 bytes)
UPDATE angular.json (2113 bytes)

The CLI did three things: an example worker file, a separate TypeScript configuration for workers, and "webWorkerTsConfig": "tsconfig.worker.json" in the build options of angular.json. Worker code is compiled with the webworker types instead of lib.dom; that way, trying to access document inside a worker is a compile error. I moved the generated "lib": ["es2018", "webworker"] to es2022; the rest of the app targets ES2022 and there is no reason to stay on an older lib in the worker:

/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/worker",
    "lib": ["es2022", "webworker"],
    "types": []
  },
  "include": ["src/**/*.worker.ts"]
}

The first build, however, broke in an unrelated file:

Application bundle generation failed. [0.520 seconds] - 2026-11-14T20:20:48.678Z

✘ [ERROR] TS2739: Type 'Event' is missing the following properties from type 'BeforeInstallPromptEvent': platforms, userChoice, prompt [plugin angular-compiler]

    src/app/core/install-prompt.ts:48:6:
      48 │       this.deferred = event;
         ╵       ~~~~~~~~~~~~~

The error is in InstallPrompt from post 06. The reason: tsconfig.app.json compiles all of src/**/*.ts, and the worker file falls into that. The /// <reference lib="webworker" /> line at the top of the worker file adds the worker's global types to the app's compilation; the type of the global addEventListener changes, and the beforeinstallprompt type we added to WindowEventMap is no longer used. The fix is to leave worker files out of the app compilation:

  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "src/**/*.spec.ts",
    "src/**/*.worker.ts"
  ]

That is not enough on its own: if app code imports anything from the worker file, even just a type (import type), the worker file enters the compilation again. I tried it and the same error came back. That is why the type of the API the worker exposes lives in the shared catalog-search.ts, not in the worker file:

// what the worker exposes through Comlink; kept here so the app never imports the worker file
export interface CatalogSearchApi {
  load(url: string): Promise<number>;
  search(query: string): SearchHit[];
}

The example the CLI generated talks with postMessage and the message event. In a worker with more than two methods that means a hand-written protocol for message types, request IDs and matching answers. Comlink (a small Apache 2.0 licensed library from GoogleChromeLabs) hides this behind a proxy: the worker exposes an object, the page calls the methods of the proxy it gets from wrap, and every call returns a promise. Bookstore's worker:

/// <reference lib="webworker" />
import { expose } from 'comlink';
import { buildIndex, CatalogEntry, CatalogSearchApi, IndexedEntry, search } from './catalog-search';

let index: IndexedEntry[] = [];

const api: CatalogSearchApi = {
  // the worker downloads, parses and indexes the 5,000 books itself: the main thread does none of it
  async load(url: string): Promise<number> {
    const entries: CatalogEntry[] = await (await fetch(url)).json();
    index = buildIndex(entries);
    return index.length;
  },
  search(query) {
    return search(index, query);
  },
};

expose(api);

The worker downloads the catalog itself; JSON parsing and indexing also happen in the worker. The main thread only handles answers of 20 results. The service that creates and wraps the worker:

import { Service } from '@angular/core';
import { Remote, wrap } from 'comlink';
import { CatalogSearchApi, SearchHit } from './catalog-search';

@Service()
export class CatalogSearchService {
  private api?: Remote<CatalogSearchApi>;
  private loaded?: Promise<number>;

  // one worker for the whole app, created on first use and never per component
  private start() {
    if (!this.api) {
      const worker = new Worker(new URL('./catalog-search.worker', import.meta.url), {
        type: 'module',
      });
      this.api = wrap<CatalogSearchApi>(worker);
      this.loaded = this.api.load(new URL('/api/catalog/index', location.origin).href);
    }
    return { api: this.api, loaded: this.loaded! };
  }

  ready(): Promise<number> {
    return this.start().loaded;
  }

  async search(query: string): Promise<SearchHit[]> {
    const { api, loaded } = this.start();
    await loaded;
    return api.search(query);
  }
}

The new Worker(new URL('./catalog-search.worker', import.meta.url), { type: 'module' }) pattern matters: Angular's build system recognizes this expression and compiles the worker as a separate bundle. In the build output the worker shows up as its own chunk:

chunk-ZXVHSyDF.js   | search-page           |   7.70 kB |                 3.13 kB
worker-OKO75KY4.js  | catalog-search-worker |   5.07 kB |                 2.07 kB
Application bundle generation complete. [0.742 seconds] - 2026-11-14T20:28:43.490Z

The service is root-level and creates the worker once, on first use. A component that creates a worker every time the search page opens, and does not call terminate() when leaving, leaves one more worker and one more copy of the index behind on every visit. In the measurement below, after leaving the search page and coming back twice, the number of workers on the page is still 1.

Measurement: Main Thread, scheduler.yield and Worker

For measuring, the search page accepts ?mode=main and ?mode=yield; the app uses the worker by default. With Playwright I typed "midlemarch eliot" into the box, waiting 120 ms between keys, and collected the duration of each interaction with the Event Timing API. "slowest" is the slowest interaction; INP is computed from this value. I ran Chromium once at normal speed and once with the CPU slowed down 4 times through the DevTools protocol, to imitate a mid-range phone:

chromium mode=main   load    44 ms | 16 keys: slowest 80 ms, >200 ms: 0, long tasks: 12, results 68 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium mode=yield  load    58 ms | 16 keys: slowest 40 ms, >200 ms: 0, long tasks: 0, results 79 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium mode=worker load    59 ms | 16 keys: slowest 32 ms, >200 ms: 0, long tasks: 0, results 67 ms after the last key | workers: 1 | first hit: Middlemarch (1926 edition), George Eliot
chromium (CPU x4) mode=main   load   147 ms | 16 keys: slowest 408 ms, >200 ms: 11, long tasks: 16, results 260 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium (CPU x4) mode=yield  load   154 ms | 16 keys: slowest 56 ms, >200 ms: 0, long tasks: 0, results 266 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium (CPU x4) mode=worker load   141 ms | 16 keys: slowest 40 ms, >200 ms: 0, long tasks: 0, results 67 ms after the last key | workers: 1 | first hit: Middlemarch (1926 edition), George Eliot
webkit mode=main   load    65 ms | 16 keys: slowest 40 ms, >200 ms: 0, long tasks: 0, results 37 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
webkit mode=yield  load    48 ms | 16 keys: slowest 16 ms, >200 ms: 0, long tasks: 0, results 52 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
webkit mode=worker load    68 ms | 16 keys: slowest 16 ms, >200 ms: 0, long tasks: 0, results 44 ms after the last key | workers: 1 | first hit: Middlemarch (1926 edition), George Eliot

At desktop speed all three approaches stay under 200 ms; that is why the teammate feels no slowness. Even so, the main-thread search produces a long task on every key. When the CPU is 4 times slower the picture changes: 11 of the 16 key presses went over 200 ms, the slowest took 408 ms. The "good" threshold described in the Web Performance series' INP post is 200 ms; on a phone this page is at twice that. Writing async does not change this: MainThreadSearch.search() is already async, but the calculation inside is one synchronous piece; the promise only changes when the result is delivered, not on which thread the work runs. That is the answer to the prediction.

In the worker the slowest interaction is 40 ms and the results are on screen 67 ms after the last key. A caveat: in this measurement the DevTools protocol's CPU slowdown affected the page's main thread and did not noticeably change the worker's speed (the result time is almost the same as the run without slowdown). On a real phone the search in the worker slows down too; but because the slow work is not on the main thread, typing in the box stays smooth and the delay only shows in the result list. In WebKit the long task count is 0 on every line because WebKit does not support the Long Tasks API; Event Timing, on the other hand, has been in Safari since 26.2 (Baseline Newly Available since December 2025, according to web-features).

Splitting on the Main Thread with scheduler.yield

A worker is not always the best option: if the work touches the DOM, or the complexity of setting up a worker is not worth the size of the work, splitting the long task into slices and giving the main thread back in between is another way. The yield mode in the measurement does that:

  // 500 books per slice, giving the main thread back in between; a newer query aborts this one
  async searchYielding(query: string, signal?: AbortSignal): Promise<SearchHit[]> {
    const index = await this.ready();
    const queryTerms = terms(query);
    if (!queryTerms.length) return [];
    const hits: SearchHit[] = [];
    for (let i = 0; i < index.length; i += 500) {
      hits.push(...searchSlice(index.slice(i, i + 500), queryTerms));
      await yieldToMain();
      signal?.throwIfAborted();
    }
    return rank(hits);
  }
}

function yieldToMain(): Promise<void> {
  // lib.dom.d.ts declares scheduler everywhere, but only Chromium 129+ and Firefox 142+ have it
  if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve)); // Safari: a macrotask
}

scheduler.yield() gives the main thread back to the browser and the waiting key events get their turn; the continuing work, unlike with setTimeout, is put at the front of the queue rather than the back. According to BCD it exists since Chrome 129 and Firefox 142, not in Safari; TypeScript 6.0's lib.dom.d.ts, however, assumes scheduler exists everywhere, so the 'scheduler' in globalThis check is required. In the measurement, with the CPU 4 times slower, the slowest interaction dropped to 56 ms.

This has a cost, and my first attempt showed it. In the first version old searches were not cancelled: Angular's resource ignores the previous loader's result when a new query arrives, but the sliced search kept doing its work on the main thread. The 16 searches started on every key interleaved and the final result appeared 1,300 ms after the last key. Checking the abortSignal that resource gives the loader after every slice brought this down to 266 ms. Cancellation patterns are in the Advanced JavaScript series post "Advanced JavaScript Async Patterns: The Promise.all Family, Cancellation, Retry and Timeouts". Old searches sent to the worker, on the other hand, are not cancelled; the worker runs them in order. Since a search takes 70 ms this caused no measurable problem; for heavier work you need a short debounce or a cancellation mechanism inside the worker.

What to Move to a Worker, and What Not

All data going to and from the worker is copied with structured clone. I measured the cost of copying the 5,000-book array:

chromium 5,000 books: structuredClone 1.3 ms, postMessage to a worker and back 1.2 ms (average of 10)
webkit 5,000 books: structuredClone 2.0 ms, postMessage to a worker and back 2.6 ms (average of 10)

Between 1 and 3 ms for 672 KB of data; in Bookstore copying is not the real cost. The real gain is that JSON parsing after the download, indexing and every search leave the main thread. Copying only matters when the data is megabytes in size or goes back and forth very often; then you transfer the ArrayBuffer (Transferable) instead of copying it. Work worth moving to a worker: client-side search and filtering, parsing and validating a large CSV or Excel import, image downscaling (with OffscreenCanvas, Baseline since March 2023 according to web-features), encryption and compression. Work not worth moving: anything that touches the DOM (there is no DOM in a worker), small calculations of a few milliseconds (the messaging itself takes about that long) and work that needs to share state often.

If you want to share the same worker across several tabs there is SharedWorker. According to web-features it has been Baseline Newly Available since May 2026, when Chrome Android added it in 148. In Bookstore it is acceptable for each tab to have its own index; if you want a single index instead of five copies for a user with five tabs open, you can move to a SharedWorker. The general tools for coordinating tabs are the topic of the next post.

In Production

Take the measurement from the field. The numbers in this post are lab measurements; collecting real users' INP with web-vitals is the topic of the Web Performance series' first post. The search page's INP should be tracked separately before and after the move to the worker.

Do not miss errors in the worker. Comlink carries an error thrown in the worker to the caller as a promise rejection; but if loading the worker fails (a wrong path, CSP's worker-src blocking it), the page only gets the worker's error event. The service could listen for that event and fall back to the main thread for search. If you use CSP, the worker's bundle file must be allowed by worker-src 'self'; applying CSP to Angular is covered in the Frontend Security series post "Applying a Strict CSP to Angular: Nonces, strict-dynamic, autoCsp and a Gradual Rollout with Report-Only".

The worker's memory is separate. The 5,000-book index lives in the worker and stays until the page closes. In an app that stays open for a long time, closing an unused worker with terminate() and creating it again when needed is good practice for memory.

Common Pitfalls

  • Putting a heavy calculation into an async method and assuming it left the main thread. A promise does not move work to another thread.
  • Creating the worker in a component's constructor and never calling terminate(). Every visit adds a new worker and a new copy of the data; create the worker once in a root-level service.
  • Importing the worker file from app code, even only for a type. The worker's webworker types leak into the app compilation; put shared types in a separate file and exclude worker files from tsconfig.app.json.
  • Downloading and parsing large data in the page and then sending it to the worker. The main thread has already done the expensive part; let the worker download the data itself.
  • Not cancelling old runs of sliced (yielding) work. Interaction looks smooth but the result arrives seconds later.
  • Measuring on the desktop and concluding "nothing is slow". Measure with CPU slowdown or on a real mid-range phone.

A Small Experiment

★☆☆ Run the measurement script with the CPU 6 times slower and compare the slowest interactions of the three modes. How do the slowest interaction and the result time change in yield mode when you reduce the slice size from 500 to 100?

★★☆ Add a cancellation mechanism to the worker: give each search an increasing number, and have the worker drop the old search between 500-book slices when a new one arrives. Measure the time to the final result for a 30-character query typed very fast, before and after.

★★★ Write an OrderReportWorker that parses the order report CSV from this series' first post (20,000 rows) in a worker and summarizes it. Use the fetch response's body inside the worker to read the stream there, and compare the number of long tasks with the main-thread version.

A worker splits up work inside one tab. When the same user opens Bookstore in five tabs, other problems appear: when one tab logs out the others do not know, every tab polls the server separately, and the outbox is emptied from two tabs at once. In the next post we coordinate tabs with BroadcastChannel, Web Locks, page lifecycle events and the Navigation API.