Type-safe RPC for Web Workers over MessagePort.
Courier gives you a type-safe RPC layer between a host and one or more workers. You define the message contract once in TypeScript; emit, on and bind stay fully typed on both sides.
Built at Capacities and used in our own codebase.
- End-to-end TypeScript - define your contract once;
emit,onand handler arguments/returns are checked on host and every worker. - Direct worker-to-worker calls - the host builds a full mesh of
MessageChannels, so any participant can call any other without relaying through the main thread. - Production-ready protocol - initialization handshake, optional heartbeat, per-event timeouts and disconnect handling are built in.
- Request middleware -
interceptlets you gate, log, or reject incoming RPCs before they hit your handlers. - Chrome DevTools profiling -
isProfilingrecords round-trip measurements with handler vs messaging breakdown. See Profiling.
With three participants, a worker can call another worker directly:
type Definition = {
main: {
onSaved: (id: string) => void
}
processing: {
ingest: (raw: string) => { id: string; title: string; tokens: string[] }
}
database: {
save: (record: { id: string; title: string; tokens: string[] }) => void
get: (id: string) => { id: string; title: string; tokens: string[] } | null
}
}
// Inside the processing worker - no need to bounce through main
const ipc = await courier.worker<Definition, 'processing'>('processing', self)
ipc.on('ingest', async (raw) => {
const record = {
id: crypto.randomUUID(),
title: raw.split('\n')[0] ?? '',
tokens: raw.toLowerCase().split(/\s+/),
}
await ipc.emit('database', 'save', record)
await ipc.emit('main', 'onSaved', record.id)
return record
})| Courier | Comlink | threads.js | Raw postMessage |
|
|---|---|---|---|---|
| Model | Named participants, shared contract | Proxy over an exposed object | Thread pool for parallel tasks | Ad-hoc messages |
| API | emit(target, event, …) / on(event, handler) |
Comlink.wrap(endpoint).method() |
pool.queue(fn) |
Custom protocol |
| Typing | ✅ One Definition types every participant's emit/on |
✅ Remote<T> from wrap<T>()¹ |
✅ via spawn<T>()¹ |
❌ |
| Worker-to-worker | ✅ Mesh wired by courier.host |
MessagePort; channels are DIY |
❌ Not the focus | |
| Multi-worker setup | ✅ courier.host connects all ports |
wrap() per endpoint |
✅ Worker pool | |
| Handshake & heartbeat | ✅ Built-in | ❌ | ❌ | ❌ |
| RPC timeouts & disconnect | ✅ Built-in | ❌ | ❌ | ❌ |
| Per-RPC DevTools tracks | ✅ Optional isProfiling (handler vs messaging split)² |
❌ No built-in instrumentation | ❌ No built-in instrumentation | ❌ |
| Transferables / callbacks | 🔜 Planned | ✅ Comlink.transfer, Comlink.proxy |
✅ Via Comlink |
¹ Comlink and threads.js both provide real generic typing (Remote<T>, typed spawn). The gap vs Courier is mostly shape: Courier's single Definition schema types named participants and cross-worker routes together; Comlink/threads.js type the exposed API surface, with occasional as unknown as at the edges.
² Chrome can profile any worker natively (chrome://inspect). This row is about library-level performance.measure() tracks per RPC, not whether profiling is possible at all.
✅ Built-in ·
npm install @capacities/courierDefine a definition - a map of participant names to their exposed handlers:
type Definition = {
main: {
onResult: (value: number) => void
}
worker: {
compute: (a: number, b: number) => number
}
}import { courier } from '@capacities/courier'
const ipc = courier.host<Definition, 'main'>('main', {
worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
})
ipc.on('onResult', (value) => {
console.log('result:', value)
})
const sum = await ipc.emit('worker', 'compute', 2, 3)
console.log(sum) // 5import { courier } from '@capacities/courier'
const ipc = await courier.worker<Definition, 'worker'>('worker', self)
ipc.on('compute', (a, b) => a + b)
await ipc.emit('main', 'onResult', 42)Courier handles the handshake: the host spins up MessageChannels between all participants, transfers ports, waits for initialization, then signals readiness.
Creates the host-side IPC interface. workers maps each worker name to a factory that returns a Worker (or mock).
Returns an Courier.IPC instance synchronously. Workers are terminated when close() is called.
Bootstraps a worker. Pass self (the global WorkerGlobalScope in a real worker).
Returns a Promise<Courier.IPC> that resolves once the host signals readiness.
Low-level setup when you manage MessagePorts yourself.
Creates an in-memory host/worker pair for tests - no real Worker thread needed.
import { courier } from '@capacities/courier'
const mock = courier.mock<Definition, 'worker'>('worker', (ipc) => {
ipc.on('compute', (a, b) => a + b)
})
const host = courier.host<Definition, 'main'>('main', {
worker: () => mock,
})
await host.emit('worker', 'compute', 3, 9) // 12Each side exposes:
| Method | Description |
|---|---|
emit(target, event, ...args) |
Call a handler on another participant. Returns a Promise with the result. |
on(event, callback) |
Register a handler for incoming requests. Returns an unsubscribe function. |
once(event, callback) |
Same as on, but runs at most once. |
bind(handlers) |
Register multiple handlers at once. |
close() |
Tear down listeners and ports. |
self |
This participant's name. |
Handlers may be sync or async - return values and thrown errors are propagated back to the caller.
Pass partial options to override defaults from Courier.DEFAULTS (host, worker and low-level ipc each accept a slightly different subset):
courier.host(
'main',
{ worker: () => new Worker('./worker.js') },
{
timeouts: {
// (Host only) Max time to wait for every worker to finish the initialize handshake.
initialization: 5_000,
// Max time an `intercept` callback may run before the request is rejected.
intercept: 5_000,
// Max time an `emit` may take before its promise rejects.
// Use a number for one global limit, or an object for per-target/per-event overrides.
events: Infinity,
},
// Optional liveness checks. Sends periodic pings; marks a target dead after too many misses.
heartbeat: {
interval: 1_000, // How often to ping each target (ms).
timeout: 500, // How long to wait for a pong before counting a miss (ms).
threshold: 3, // Consecutive misses before `onDead` runs and the target is closed.
isPaused: () => document.hidden, // When true, pauses probes and resets miss counters.
targets: ['worker'], // Which peers to monitor. Defaults to all connected targets.
onDead: (target) => console.warn(`${target} is unresponsive`),
},
// Middleware invoked on incoming requests before they reach your handler.
// Call `proceed()` to continue or `reject(reason)` to fail the request.
// Set to `null` (default) to disable.
intercept: (event, parameters, { proceed, reject }) => {
proceed()
},
// Called when a participant disconnects or is closed (including after heartbeat failure).
onClose: (target) => console.warn(`${target} disconnected`),
// Log internal warnings (handshake failures, port errors, heartbeat issues, etc.).
isVerbose: false,
// (Host only) Record `performance.measure()` entries for Chrome DevTools. See Profiling below.
isProfiling: false,
}
)Worker options are the same except there is no timeouts.initialization and isProfiling / isVerbose are inherited from the host during handshake rather than set on the worker directly.
bind(handlers, { isOnce: true }) accepts a listen option to auto-unsubscribe after the first matching request.
Courier has built-in support for the Performance API and Chrome DevTools custom tracks. Enable it on the host - the setting is forwarded to every worker during handshake:
const ipc = courier.host<Definition, 'main'>(
'main',
{ worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) },
{ isProfiling: true }
)Every emit then records a performance.measure() entry that shows up in the Chrome Performance panel.
Typical workflow:
- Set
isProfiling: truein development (keep it off in production). - Open Chrome DevTools → Performance.
- Record a session while your app runs worker RPCs.
- Look for the Courier track group - each bar is one typed
emit, hover for the computation/communication breakdown.
- Browser - relies on
Worker,MessageChannelandMessagePort - ESM - this package ships as ES modules only
pnpm install
pnpm test
pnpm typecheck
pnpm lint
pnpm buildMIT © Capacities Labs GmbH