Browser tab as a Resonate worker in TypeScript

A Resonate worker normally runs in a long-lived server process; placing one inside a browser tab moves where execution lives but does not change the durable-execution contract. The Resonate TypeScript SDK is designed so the same Resonate client that runs in Node can run inside a browser tab: it subscribes to the server over a Server-Sent Events (SSE) stream, claims tasks for its worker group, runs the registered function inside the tab's JavaScript runtime, and persists every checkpoint to the server. The example registers a recursive factorial generator and triggers it by creating a durable promise from the CLI, which the tab claims and executes — provided the SDK is reachable in the browser and the client is given a server URL (see "Caveats" below — at SDK 0.10.0 the example as shipped does not build for the browser without modification).

The shape of the solution

import { Resonate, Context } from "@resonatehq/sdk";

declare const document: any;

function addExecutionStep(functionCall: string, status: string = "executing") {
const logBody = document.getElementById("execution-log");
if (logBody) {
const row = document.createElement("tr");
row.innerHTML = `
${status}
${functionCall}
`;
logBody.appendChild(row);
}
}

function* factorial(ctx: Context, i: number): Generator<any, number, any> {
addExecutionStep(`factorial(${i})`, "micro restart");
console.log(`factorial(${i}) micro restart`);
if (i == 0) {
return 1;
} else {
//return i * (yield* ctx.run(factorial, i - 1));
return i * (yield* ctx.rpc("factorial", i - 1));
}
}

async function main() {
const resonate = new Resonate({});
resonate.register("factorial", factorial);
}

main().catch((error) => {
console.error("Uncaught error in main():", error);
addExecutionStep("main()", "error");
});
// from example-browser-worker-ts/src/worker.ts:1-36 (verbatim)

worker.ts is bundled by esbuild into public/worker.js as an ES module and loaded by a tag in public/index.html:39. There is no server-side worker process — the only thing running outside the browser is the Resonate server.

The durable primitives in play

new Resonate({}) — constructs the client. The constructor resolves a URL via url ?? (process.env.RESONATE_URL || undefined); when a URL is present the SDK wires HttpNetwork plus a PollMessageSource against <url>/poll/<group>/<pid>; otherwise it falls back to an in-memory LocalNetwork. The default worker group is "default". Source: resonate-sdk-ts/src/resonate.ts:101-173 and :103 for the group default. In this example as shipped there is no URL passed to new Resonate({}) and no esbuild --define for process.env.RESONATE_URL — see "Caveats" below.

resonate.register("factorial", factorial) — registers the generator under the string name "factorial" so durable promises whose data.func is "factorial" can be dispatched to this worker. Source: example-browser-worker-ts/src/worker.ts:30 and resonate-sdk-ts/src/resonate.ts:253-294.

ctx.rpc("factorial", i - 1) — issues a Remote Function Call (RFC, the value-returning sibling of RFI / Remote Function Invocation). Each call creates a child durable promise on the Resonate server and yields control until that promise resolves; the server routes the child task to any worker in the default group (which can be the same tab or another tab). Source: example-browser-worker-ts/src/worker.ts:24, resonate-sdk-ts/src/context.ts:215-217 (the rpc/rfc interface alias), :277 (rpc = this.rfc.bind(this)), and :457-488 (the rfc method body that calls remoteCreateReq).

The SSE-based message source — PollMessageSource is named for the poll:// address scheme it implements, but the wire transport is Server-Sent Events: it opens new EventSource(<url>/poll/<group>/<pid>) to receive task assignments. Source: resonate-sdk-ts/src/network/http.ts:242-292 (class header through the connect() body + new EventSource(this.pollUrl, ...) at :288). The README confirms this: "HTTP API for task claiming, SSE for real-time updates" (README.md:75).

The CLI-side trigger — the README creates the root invocation with resonate promises create factorial.5 --idempotency-key factorial.5 --tag resonate:invoke=poll://any@default --data '{"func":"factorial","args":[5]}'. The poll://any@default invoke tag is the address scheme PollMessageSource answers to: any worker registered in the default group can claim it. Source: example-browser-worker-ts/README.md:47-53; the matching anycast scheme is constructed at resonate-sdk-ts/src/network/http.ts:277 (this.anycast = `poll://any@${group}/${pid}` ).

What the SDK handles vs. what you write

You write a generator function whose only side-effecting operations are yields into ctx.* primitives. The SDK does everything else.

You write | The SDK handles
The generator body of factorial, the base case, and the recursive case expressed as yield* ctx.rpc(...) | Cross-runtime execution: the same generator runs in Node, Bun, or a browser tab when bundling resolves
One call to resonate.register("factorial", factorial) | Registering the function name with the local registry and making it claimable by tasks delivered over the SSE stream
new Resonate({ url }) with a server URL (or RESONATE_URL in the environment) | Resolving the server URL, opening the SSE subscription, claiming tasks, heartbeating, decoding the function name + args, and dispatching to the registered generator
The CLI command that creates the root durable promise | Server-side storage of every factorial(n) durable promise, routing of poll://any@default to a worker in the default group, resolution propagation when the base case returns

The load-bearing point: the browser tab is not "the worker plus some plumbing." It is only the registered generator. Everything that makes a worker a worker — task acquisition, heartbeats, retries, durable state — is in the SDK and the server.

Failure modes covered

Tab refresh mid-computation. Each ctx.rpc("factorial", n-1) writes a durable promise to the server before yielding (src/worker.ts:24; SDK context.ts:457-488). When the tab reloads, main() re-runs resonate.register("factorial", factorial), the SDK re-opens its SSE subscription, and the server hands the still-pending child task back. The recursion resumes from where it suspended; only the still-pending leaf gets re-executed.

Replays re-run the generator body from the top. The example logs factorial(n) micro restart at the top of the body (src/worker.ts:18-19, via the addExecutionStep helper at src/worker.ts:5-15) so this is visible in the execution-log table. Pure code at the top of a generator runs on every replay; durable values come from completed yield* checkpoints. Anything that must run exactly once must live inside a ctx.run / ctx.rpc checkpoint.

Duplicate triggers. The README's --idempotency-key factorial.5 (README.md:49) collapses repeated resonate promises create factorial.5 ... invocations onto the same durable promise. Re-running the CLI command does not start a second computation.

Tab closed entirely. While the tab is gone there is no worker in the default group to claim pending tasks, so the computation pauses at whatever leaf was running. When any tab in the group comes back up and re-registers factorial, the server routes the pending task to it.

The example does not handle:

Server restart. The README starts the server with --aio-store-sqlite-path :memory: (README.md:34), an in-memory store, so in-progress computations are lost if the server restarts. Switch to a non-memory SQLite path or Postgres for durability across server restarts.

Offline workers. Closing the tab pauses the work; the durable promise persists, but it sits pending until a worker registers again.

Caveats (the example as shipped)

These are not voice issues — they are facts about the repo at the pinned SDK version that an agent following the post needs to know before treating it as a working build.

The example only runs in Chrome. The README explicitly states "Currently only works in chrome" (README.md:11).

new Resonate({}) does not reach the server as written. The example passes no url and the build does not inject process.env.RESONATE_URL. In a browser, process is undefined; under most esbuild defaults process.env.X stubs to undefined, which sends the SDK down the LocalNetwork branch (resonate.ts:170-173) — an in-memory network that cannot receive the CLI-created promise. To actually reach a Resonate server, change the constructor to new Resonate({ url: "http://localhost:8001" }) or pass --define:'process.env.RESONATE_URL="http://localhost:8001"' to the esbuild step in package.json:8.

At @resonatehq/sdk@0.10.0, npm run build fails. The shipped SDK bundle imports node:crypto, node:http, and node:timers/promises unconditionally (dist/resonate.js:1-2, dist/core.js:1, dist/network/http.js:1, dist/heartbeat.js:1, dist/schedules.js:1); esbuild cannot resolve node:-namespace imports for a browser target without --platform=node or per-import shims. The post documents the intended shape; an agent reproducing the example will hit this until a later SDK release decouples the browser-targetable surface from the Node-only imports.

When to reach for this pattern

If you need a worker that runs in the user's environment rather than your servers — for example, a workflow that must access the DOM, local hardware (WebUSB, WebMIDI, WebSerial), browser-only APIs, or per-user credentials that never leave the device.

If you want to demo or debug a Resonate workflow with a visible execution log without provisioning server infrastructure for the worker.

If you need to fan a workflow out to many human-attended tabs (each tab in the same group is a claimable worker) and you do not have a managed worker pool.

If the workflow's correctness depends on running on the user's machine but you still want server-side durability and retry.

Not for: any workload that must run when the user is offline. Closing the tab pauses the work; the durable promise persists on the server, but it sits pending until a worker registers again.

Sources

Example repo: https://github.com/resonatehq-examples/example-browser-worker-ts

Resonate TypeScript SDK (version pin ^0.10.0, resolves to 0.10.0 in the example's package-lock.json): https://github.com/resonatehq/resonate-sdk-ts

SDK files referenced (pinned to the v0.10.0 tag to match the example's lockfile):

Constructor + URL resolution + network selection: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L101-L173

Default worker group: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L103

register overloads + implementation: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L253-L294

rpc / rfc interface alias: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L215-L217

rpc = this.rfc.bind(this): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L277

rfc method body (issues remoteCreateReq): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L457-L489

PollMessageSource (uses EventSource for SSE transport, anycast scheme construction): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/network/http.ts#L242-L292

Resonate server (Rust): https://github.com/resonatehq/resonate