Skip to content
easy-web-worker

Typed workers

How defineWorker and createWorker turn the functions of a worker into typed methods on the main thread.

GuideTypeScriptTry an example

The worker is an API

Every key you return from defineWorker becomes a method on the main thread.

text.worker.ts
import { defineWorker } from 'easy-web-worker/defineWorker';

const worker = defineWorker(() => ({
  countWords: (text: string) => text.trim().split(/\s+/).length,

  findDuplicates: (lines: string[]) =>
    lines.filter((line, index) => lines.indexOf(line) !== index),

  // no payload: called without arguments
  version: () => '1.0.0',
}));

export type TextWorker = typeof worker;
main.ts
import { createWorker } from 'easy-web-worker/createWorker';
import workerUrl from './text.worker?worker&url';
import type { TextWorker } from './text.worker';

const text = createWorker<TextWorker>(workerUrl, {
  workerOptions: { type: 'module' },
});

export async function analyze(article: string, lines: string[]) {
  const words = await text.countWords(article); // number
  const duplicates = await text.findDuplicates(lines); // string[]
  const version = await text.version(); // string

  return { words, duplicates, version };
}

The contract between the threads lives next to the code that implements it. There is no second file of message types to keep in sync: change the worker, and the main thread stops compiling until it matches.

How the types cross the threads

In the workerOn the main thread
() => string() => CancelablePromise<string>
(value: number) => number(payload: number, transfer?) => CancelablePromise<number>
async (ids: string[]) => User[](payload: string[], transfer?) => CancelablePromise<User[]>
(value?: number) => number(payload?: number, transfer?) => CancelablePromise<number>

A method receives one payload. To send several values, use an object or a tuple.

Return values and errors

What a method returns resolves the call. What it throws rejects it, and the worker keeps serving the next call.

parser.worker.ts
import { defineWorker } from 'easy-web-worker/defineWorker';

const worker = defineWorker(() => ({
  parse: (json: string) => {
    if (!json) throw new Error('Empty input');

    return JSON.parse(json) as unknown;
  },
}));

export type ParserWorker = typeof worker;
main.ts
import { createWorker } from 'easy-web-worker/createWorker';
import workerUrl from './errors.worker?worker&url';
import type { ParserWorker } from './errors.worker';

const parser = createWorker<ParserWorker>(workerUrl, {
  workerOptions: { type: 'module' },
});

export async function safeParse(json: string) {
  try {
    return await parser.parse(json);
  } catch (error) {
    // the Error thrown inside the worker, rebuilt on this side
    console.log((error as Error).message); // 'Empty input'

    return null;
  }
}

Values travel between threads with the structured clone algorithm. Objects, arrays, Map, Set, Date, typed arrays and Error cross; functions, DOM nodes and class instances with methods do not.

State inside a worker

A worker stays alive between calls, so it can own state.

users.worker.ts
import { defineWorker } from 'easy-web-worker/defineWorker';

type User = { id: string; name: string };

// module scope of the worker: it lives as long as the worker does
const users = new Map<string, User>();

const worker = defineWorker(() => ({
  save: (user: User) => {
    users.set(user.id, user);
  },

  get: (id: string) => users.get(id) ?? null,

  remove: (id: string) => users.delete(id),
}));

export type UsersWorker = typeof worker;

With a pool, each Worker has its own memory. State kept in one is not visible to the others.

Two entry points

Import each side from its own entry, so the worker bundle carries no main-thread code.

EntryUse it inExports
easy-web-worker/defineWorkerThe worker filedefineWorker, unwrap, types
easy-web-worker/createWorkerThe main threadcreateWorker, unwrap, types

Everything is also exported from the root easy-web-worker entry.

What it replaces

The native API gives you messages, not functions. This is the least you write by hand to get one answer back:

fibonacci.worker.js
const fibonacci = (n) => (n <= 1 ? n : fibonacci(n - 1) + fibonacci(n - 2));

self.onmessage = ({ data: { id, n } }) => {
  self.postMessage({ id, result: fibonacci(n) });
};
main.js
const worker = new Worker(new URL('./fibonacci.worker.js', import.meta.url));
const pending = new Map();
let nextId = 0;

worker.onmessage = ({ data: { id, result } }) => {
  pending.get(id)(result);
  pending.delete(id);
};

export const fibonacci = (n) =>
  new Promise((resolve) => {
    const id = nextId++;

    pending.set(id, resolve);
    worker.postMessage({ id, n });
  });

And that is before errors, cancellation, progress, types, or a second method.

Documentation
↑↓ Navigate ↵ Open Esc Closeeasy-web-worker docs
Get started

Real Web Workers. Typed methods.
Cancelable promises.