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 = `
<td><span class="tag is-info">${status}</span></td>
<td><code>${functionCall}</code></td>
`;
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 <script type="module"> 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 viaurl ?? (process.env.RESONATE_URL || undefined); when a URL is present the SDK wiresHttpNetworkplus aPollMessageSourceagainst<url>/poll/<group>/<pid>; otherwise it falls back to an in-memoryLocalNetwork. The default worker group is"default". Source:resonate-sdk-ts/src/resonate.ts:101-173and:103for the group default. In this example as shipped there is no URL passed tonew Resonate({})and no esbuild--defineforprocess.env.RESONATE_URL— see "Caveats" below.resonate.register("factorial", factorial)— registers the generator under the string name"factorial"so durable promises whosedata.funcis"factorial"can be dispatched to this worker. Source:example-browser-worker-ts/src/worker.ts:30andresonate-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 thedefaultgroup (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(therpc/rfcinterface alias),:277(rpc = this.rfc.bind(this)), and:457-488(therfcmethod body that callsremoteCreateReq).- The SSE-based message source —
PollMessageSourceis named for thepoll://address scheme it implements, but the wire transport is Server-Sent Events: it opensnew EventSource(<url>/poll/<group>/<pid>)to receive task assignments. Source:resonate-sdk-ts/src/network/http.ts:242-292(class header through theconnect()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]}'. Thepoll://any@defaultinvoke tag is the address schemePollMessageSourceanswers to: any worker registered in thedefaultgroup can claim it. Source:example-browser-worker-ts/README.md:47-53; the matching anycast scheme is constructed atresonate-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; SDKcontext.ts:457-488). When the tab reloads,main()re-runsresonate.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 restartat the top of the body (src/worker.ts:18-19, via theaddExecutionStephelper atsrc/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 completedyield*checkpoints. Anything that must run exactly once must live inside actx.run/ctx.rpccheckpoint. - Duplicate triggers. The README's
--idempotency-key factorial.5(README.md:49) collapses repeatedresonate 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
defaultgroup to claim pending tasks, so the computation pauses at whatever leaf was running. When any tab in the group comes back up and re-registersfactorial, 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 nourland the build does not injectprocess.env.RESONATE_URL. In a browser,processis undefined; under most esbuild defaultsprocess.env.Xstubs toundefined, which sends the SDK down theLocalNetworkbranch (resonate.ts:170-173) — an in-memory network that cannot receive the CLI-created promise. To actually reach a Resonate server, change the constructor tonew Resonate({ url: "http://localhost:8001" })or pass--define:'process.env.RESONATE_URL="http://localhost:8001"'to the esbuild step inpackage.json:8.- At
@resonatehq/[email protected],npm run buildfails. The shipped SDK bundle importsnode:crypto,node:http, andnode:timers/promisesunconditionally (dist/resonate.js:1-2,dist/core.js:1,dist/network/http.js:1,dist/heartbeat.js:1,dist/schedules.js:1); esbuild cannot resolvenode:-namespace imports for a browser target without--platform=nodeor 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 to0.10.0in the example'spackage-lock.json):https://github.com/resonatehq/resonate-sdk-ts - SDK files referenced (pinned to the
v0.10.0tag 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 registeroverloads + implementation:https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L253-L294rpc/rfcinterface alias:https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L215-L217rpc = this.rfc.bind(this):https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L277rfcmethod body (issuesremoteCreateReq):https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L457-L489PollMessageSource(usesEventSourcefor SSE transport, anycast scheme construction):https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/network/http.ts#L242-L292
- Constructor + URL resolution + network selection:
- Resonate server (Rust):
https://github.com/resonatehq/resonate
