Actor-model architecture for the browser. One programming model for every boundary in your app — components, tabs, and Web Workers — built on plain message passing.
import { createActor, request, response } from 'webactor';
const math = createActor('math', (ctx) => {
ctx.addEventListener('message', (e) => {
if (e.data.type === 'sum') response(ctx, e, e.data.a + e.data.b);
});
});
math.launch();
const res = await request(math, { type: 'sum', a: 2, b: 3 });
console.log(res.data); // 5Your app is already concurrent — workers, tabs, streams, agents — but the browser's primitives for it (Worker, SharedWorker, MessagePort, postMessage) are low-level and inconsistent. webactor is one uniform actor model over all of them.
- Isolated state. An actor owns its data; the only way in or out is a message.
- Location transparency. Same API in-thread, across tabs, across workers. Move an actor into a worker without touching its logic.
- Fault tolerance. Supervisors restart crashed actors and dead workers — "let it crash" on the client.
- Small blast radius. A mistake stops at one mailbox, which is what lets people and agents work on the same app in parallel.
npm install webactor
# or: pnpm add webactorAn actor is an isolated unit with a mailbox. You never call it — you send it an envelope (a typed message). Actors are wired together with connections. Once connected, three communication patterns cover almost everything:
| Pattern | Function | Use it for |
|---|---|---|
| Fire-and-forget | postMessage |
events, notifications, state broadcasts |
| Request / response | request / response |
"ask and await an answer", RPC-style calls |
| Channel | openChannel / supportChannel |
a dedicated, disconnect-aware session between two actors |
The same three patterns work identically across a Worker boundary.
import { createActor, connectActors, ActorContext } from 'webactor';
// A stateful business actor — owns the counter, no one else can touch it.
const counter = createActor('counter', (ctx: ActorContext) => {
let value = 0;
ctx.addEventListener('message', (e) => {
if (e.data.type === 'inc') value++;
if (e.data.type === 'dec') value--;
ctx.postMessage({ type: 'value', value }); // broadcast new state
});
});
// A UI actor — renders, never owns business state.
const ui = createActor('ui', (ctx: ActorContext) => {
ctx.addEventListener('message', (e) => {
if (e.data.type === 'value') render(e.data.value);
});
document.querySelector('#inc')!.addEventListener('click', () => ctx.postMessage({ type: 'inc' }));
});
const disconnect = connectActors(ui, counter);
ui.launch();
counter.launch();
// later: disconnect(); ui.close(); counter.close();counter doesn't care where it lives. Put it in a SharedWorker (shared across every tab) and connect the whole thing with a dense network:
// main.ts
import { createDenseNetwork } from 'webactor';
import { createUIActor } from './ui-actor';
const worker = new SharedWorker(new URL('./server.worker.ts', import.meta.url), { type: 'module' });
const network = createDenseNetwork(createUIActor(), worker); // auto-detects the worker
network.launch();// server.worker.ts
import { createDenseNetwork, useContextMessagePort } from 'webactor';
import { createCounterActor } from './counter-actor';
createDenseNetwork(useContextMessagePort(), createCounterActor()).launch();The UI actor's request(...) / postMessage(...) calls are unchanged. Routing across the thread boundary is automatic.
import { applyActorSupervisor, Reasons } from 'webactor';
const supervised = applyActorSupervisor(
() => createCounterActor(),
{ shouldRetry: (reason) => reason !== Reasons.Close }, // restart on crash, not on intentional close
);
supervised.launch(); // if the inner actor throws/closes unexpectedly, it's rebuiltThere's an applyWorkerSupervisor too — it detects a dead/crashed Worker (via the Web Locks API) and respawns it.
createActor/createActorFactory— build isolated units.connectActors/connectActorToWorker/connectActorToMessagePort— wire units across any boundary.createDenseNetwork— full-mesh several actors and workers at once.createRetranslator— a transparent relay node for building hub/bridge topologies.request/response— awaitable RPC with automatic back-routing across the mesh.openChannel/supportChannel— dedicated, disconnect-aware pipes (great for per-client sessions).applyActorSupervisor/applyWorkerSupervisor— "let it crash" restart strategies.useContextMessagePort/onConnectMessagePort— the Worker/SharedWorkerside of the wire.- Envelopes, transferables, pluggable providers (timers, locks, logger) for testing & non-browser runtimes.
Full API, internals, routing model, and patterns: documentation.md.
A Chrome DevTools panel ships alongside the library: the live actor graph across every thread, the connections between actors, and the envelopes flowing over them — plus per-actor message history with a payload inspector.
pnpm --filter webactor-devtools build # load packages/devtools/dist as an unpacked extensionSee packages/devtools. The recorder lives in the library but stays inert until something installs a sink, so there is no cost when the extension is not present. You can also drive it yourself:
import { enableDevtools, getDevtoolsSnapshot } from 'webactor';
enableDevtools();
console.log(getDevtoolsSnapshot()); // { thread, nodes, links, messages }This is a pnpm workspace:
| Package | What it is |
|---|---|
packages/webactor |
the library (src), unit tests (tests), load tests (e2e) |
packages/devtools |
webactor-devtools, the Chrome DevTools extension |
examples/simple |
minimal UI ↔ business actor split |
examples/chat |
multi-tab chat over a SharedWorker |
pnpm install
pnpm build # every package
pnpm test # unit + e2e + devtools
pnpm test:unit # vitest, packages/webactor/tests
pnpm test:e2e # playwright load tests, packages/webactor/e2e
pnpm test:devtools # playwright panel + unpacked-extension testsGreat fit
- Apps with heavy
Worker/SharedWorkeruse, or that want to move work off the main thread. - Multi-tab coordination and real-time sync through a
SharedWorker. - Clear separation of UI ↔ domain logic, offline-first, or long-lived background processing.
- Anything that needs fault isolation and automatic restart of subsystems.
Probably overkill
- "Call one function in a worker and get a result." Reach for Comlink — it's a thinner RPC wrapper.
- Small apps where a component tree + a state manager already fit comfortably.
webactor is a model, not just an RPC shim: you adopt actors, envelopes, and supervision. That's a real mental investment — worth it when the payoff (isolation, fault tolerance, location transparency) matters.
| webactor | Comlink | |
|---|---|---|
| Model | Actor / message passing | Proxy-based RPC |
| In-thread + cross-worker with one API | ✅ | Worker-focused |
| Request/response | ✅ | ✅ (as proxied calls) |
| Fire-and-forget broadcasts | ✅ | Awkward |
| Dedicated sessions (channels) | ✅ | Manual |
| Supervision / restart | ✅ | ❌ |
| Multi-node topologies (mesh, relay) | ✅ | ❌ |
| Bundle / surface | Larger, opinionated | Tiny, minimal |
Single-maintainer project · MIT. The API described here is exercised by the test suite (pnpm test). Feedback and issues welcome on the repository.
Releases are automated with changesets — see CONTRIBUTING.md for how to land a change and cut a version.