runtime:workers
Durable workers: state that outlives the process, in esrun's own SQLite, with no service to run beside it.
A durable worker is addressed, not spawned. You name one; the runtime opens it on demand, runs one call at a time against it, and closes it when it has been idle. Its state is still there for the next process to name it.
State is files, so it needs --allow-read and --allow-write exactly as runtime:db does — under the same root jail and the same allowlists. The module adds no capability of its own. Status: Available.
Import
import { DurableWorker, configure, startAlarms, shutdown } from "runtime:workers";
// workers.js import { DurableWorker } from "runtime:workers"; export class Cart extends DurableWorker { async add(item) { const items = this.state.get("items") ?? []; items.push(item); this.state.set("items", items); return items.length; // held back until that write commits } async items() { return this.state.get("items") ?? []; } }
// server.js import { serve } from "runtime:http"; import { Cart } from "./workers.js"; serve(async (request) => { const cart = Cart.get(new URL(request.url).pathname.slice(1)); // no I/O yet return Response.json(await cart.items()); });
The HTML Worker is a thread with an isolate and a message port. A DurableWorker is a unit of state and single-threaded execution, and today it runs on the agent that addressed it.
What it guarantees
| One call at a time | Calls to one worker queue in its mailbox and run in order — no lock to take, no race to lose. |
| Reads are synchronous | State is resident in the worker's heap, so state.get(k) is a map lookup, not an await. |
| Writes are gated | A call's result is not handed back until the writes it made have committed. |
| The runtime owns the schema | No DDL, no SQL, no migration script. |
| One process per directory | A second is refused with ERR_DURABLE_LOCKED until the first exits. |
Exports
| Export | Type | Description |
|---|---|---|
DurableWorker | class | The base class. Extend it. |
configure | function | configure(options?) — settings, before the first worker is used. |
startAlarms | function | startAlarms({ classes }) — begins servicing alarms in this process. |
shutdown | async function | Closes every open worker and releases the directory. |
DurableError, DurableErrorCode | class, object | The failures, and their stable codes. |
The class
| Member | |
|---|---|
this.state | Its key/value state — see below |
this.id | The id it was addressed by |
this.ctx | { id, name, signal }; signal aborts when it is being closed |
start() | After the state is loaded, before the first call |
stop(reason) | Before it is closed: "idle", "shutdown" or "deleted" |
alarm() | When the alarm set on this worker comes due |
static durableName | The storage name, when the class name is not the right one |
new Cart() throws — a durable worker is addressed, because which state an instance holds is the runtime's to decide. A minified build needs static durableName: a mangled class name would address a different file.
| Static | |
|---|---|
Cart.get(id) | A reference. Nothing is opened until a method is called on it. |
Cart.delete(id) | Closes it if open, then deletes its state. Resolves to whether there was any. |
Cart.list({ limit, after }) | { id, createdAt, lastActive, bytes, live }, most recently active first. |
Arguments and results cross by structured clone, sharded or not, so what may be passed does not change when a worker moves to a shard. Lifecycle hooks are not callable through a reference, and neither is anything the class does not have — a TypeError from the call, not a promise that never settles.
A call made from inside another worker waits until the caller's writes so far are committed. A call that would make workers wait on each other in a loop throws ERR_DURABLE_CYCLE instead of waiting for ever — within one request (A calls B, which calls A) or across two (A calls B while B calls A).
state
Anything structuredClone carries can be stored — Date, Map, Set, typed arrays, BigInt, cycles — not only what JSON survives.
get(key) | Synchronous. The value, or undefined. |
set(key, value) | Resolves when durable; visible to get immediately. |
has(key), size, bytes | Synchronous. |
setMany(entries), getMany(keys), deleteMany(keys), clear() | A batch is one transaction. |
keys({ prefix, start, end, limit, reverse }) | Sorted keys. Synchronous. |
list(range) | [key, value] pairs, same narrowing. |
sync() | Waits for every write so far. |
alarm.get() / alarm.set(when) / alarm.delete() | The durable timer — see below. |
collection(name) | A declared collection — see below. |
transaction(fn) | One transaction over the keys and the collections alike. |
Mutating what get returns changes nothing on disk — a value is stored by set, not by being touched.
Before a side effect that leaves the process mid-call — a fetch, a message — await state.sync() first, or make it the value you return. A call to another durable worker needs no sync(): it waits for the caller's writes by itself.
State is resident, so its ceiling is real: 1 MiB a worker, 128 KiB a value, refused at the write with ERR_DURABLE_STATE_TOO_LARGE. What grows without bound belongs in a database of its own — runtime:db is right there.
Collections
The keys are for the small, hot thing a request needs. Collections are for what grows: documents in a table of their own, queried rather than held, and not measured against the resident ceiling.
export class Room extends DurableWorker { static schema = { collections: { messages: { index: ["ts", "author"], unique: ["clientId"] } }, }; async post(message) { return this.state.collection("messages").insert(message); } async recent(n = 20) { return this.state.collection("messages") .find({ ts: { gte: Date.now() - 86_400_000 } }) .sort({ ts: "desc" }) .limit(n) .toArray(); } }
A document is stored the way a key is — structured clone — so a Date comes back a Date. What the class declares is copied into a real indexed column beside it, and that is what can be matched and sorted.
| Declaration | |
|---|---|
index: ["ts"] | A column and an index. Matchable and sortable. |
unique: ["clientId"] | The same, unique. A collision is ERR_DB_UNIQUE_VIOLATION. |
collection(name) | |
|---|---|
insert(doc) / insertMany(docs) | Stores; resolves to the id(s) — doc.id, or a fresh UUID. |
get(id) | The document, or undefined. |
update(id, patch) | An object to merge, or a function of the document. |
delete(id) / deleteWhere(where) | Removes one, or everything selected. |
find(where?, { scan }) | A query: .sort(), .limit(), .offset(), .toArray(), .first(), .count(), for await. |
count(where?) | How many match. |
A bare value is equality; eq, ne, gt, gte, lt, lte and in are the comparisons.
Naming a field the class did not declare throws — it is inside the document, not beside it. { scan: true } reads the documents and filters here instead, which is honest work on a small collection and a full read on a large one.
state.transaction(fn) covers both halves: it commits when fn returns and rolls back when it throws. Schema changes apply on the first wake after a deploy — a newly declared field gets its column and is filled in from the documents already there. Nothing is ever dropped.
Alarms
A durable worker can ask to be woken. The time is stored beside its state, so it survives a restart — and the worker is woken whether or not anyone addresses it.
export class Reminder extends DurableWorker { async schedule(at, message) { this.state.set("message", message); await this.state.alarm.set(at); // a Date, or ms since the epoch } async alarm() { await deliver(this.state.get("message")); // setting the next one here is how a worker repeats } }
state.alarm.get() | The time set, or null. Synchronous. |
state.alarm.set(when) | A Date or ms. In the past means now. |
state.alarm.delete() | Unset. |
The alarm is cleared before the handler runs, so a handler that sets nothing is not woken again. alarm() goes through the same mailbox a call does, so it never interleaves with one. Setting an alarm on a class with no alarm() is a TypeError at the set.
A failing alarm() is retried — 1s, 2s, 4s, doubling to a five-minute cap — alarmRetries times (default 5), with the count stored so a restart does not reset it. After the last attempt it is cleared and reported.
startAlarms({ classes, onError, batch })
Nothing fires until a process says it is the one running alarms:
import { startAlarms } from "runtime:workers"; import { Reminder } from "./workers.js"; const alarms = startAlarms({ classes: [Reminder] }); await alarms.stop();
A class is not something the runtime can discover, and a scheduler that guessed would fire an alarm on a busy process and not on an idle one. Anything scheduled for a class not listed is left for the process that does list it.
| Option | |
|---|---|
classes | Required. The DurableWorker subclasses this process runs alarms for. |
onError | (error, context, worker): an alarm that failed for the last time, or a worker that could not be opened. worker is { name, id, gaveUp }, null for the scheduler itself; gaveUp means the alarm is gone for good. Defaults to console.error. |
batch | How many due workers one sweep wakes. Default 32. |
While it is running the process stays alive — which is why it is not started for you: a script that set an alarm for tomorrow should not sit there until tomorrow. stop() drops the timer and resolves once the sweep in flight has finished; shutdown() calls it.
configure(options)
Optional. With no call the defaults apply; it must come before the first worker is used, since these decide where state lives.
| Option | Default | |
|---|---|---|
dir | "./.durable" | Where state lives. A relative path is relative to the working directory, not to the entry file, so a bundled server keeps its state outside dist/ |
evictAfter | 30000 | How long a worker may idle before it is closed |
maxLive | 128 | How many may be open at once |
mailbox | 1024 | How many calls may wait on one worker |
stateLimit / valueLimit | 1 MiB / 128 KiB | The ceilings above |
alarmRetries | 5 | How many times a failing alarm() is retried |
alarmPoll | 60000 | The longest the scheduler sleeps between looks |
shards | 0 | How many shards run the classes' code; "auto" sizes it from the machine |
module | null | The module the classes are exported from, as an absolute URL. Required with shards |
permissions | [] | What a shard is granted, beside the imports it always has |
WebSockets that hibernate
A durable worker can own WebSockets. The runtime holds an accepted socket, not the worker's instance, so the worker is evicted when idle while its sockets stay connected, and the next message wakes it. The names follow Cloudflare's WebSocket Hibernation API.
import { DurableWorker } from "runtime:workers"; import { serve } from "runtime:websocket"; export class Room extends DurableWorker { join(ws, name) { this.ctx.acceptWebSocket(ws, [name]); // the runtime holds it from now on ws.serializeAttachment({ name }); // kept with the socket while the worker sleeps } webSocketMessage(ws, message) { const { name } = ws.deserializeAttachment(); for (const peer of this.ctx.getWebSockets()) peer.send(`${name}: ${message}`); } webSocketClose(ws, code, reason, wasClean) {} } for await (const ws of serve({ port: 4001 })) await Room.get("lobby").join(ws, "ana");
A socket reaches a worker as an argument: pass a runtime:websocket connection (or a client WebSocket) to a method, and it arrives as a handle, not a copy. The method takes it with ctx.acceptWebSocket; a socket it does not accept stays the caller's.
ctx.acceptWebSocket(ws, tags?) | Takes ownership. At most 10 tags, 256 characters each. |
ctx.getWebSockets(tag?) | The sockets this worker owns, optionally only those with tag. |
ctx.getTags(ws) | The tags ws was accepted with. |
ctx.setWebSocketAutoResponse({ request, response }) | Answers a message that is exactly request without waking the worker. null removes it. Held in memory, so set it in start(). |
ctx.getWebSocketAutoResponse() | The pair, or null. |
ctx.getWebSocketAutoResponseTimestamp(ws) | When ws was last auto-answered, or null. |
ws.send(data) / ws.close(code?, reason?) | Sends and closes. A send waits until the worker's writes so far are committed, and sends on one socket keep their order. |
ws.serializeAttachment(value) / ws.deserializeAttachment() | A structured-clonable value kept with the socket across hibernation. At most 16 KiB. |
The handlers are optional methods: webSocketMessage(ws, message), webSocketClose(ws, code, reason, wasClean) and webSocketError(ws, error). Each runs through the mailbox like a call, after start() if the worker was hibernating. A binary message arrives as an ArrayBuffer.
Sockets do not survive a process restart; clients reconnect. Deleting a worker, or shutdown(), closes its sockets with 1001 (going away). Sharded workers work the same way; the sockets stay on the agent that owns the directory.
Shards
With shards, a worker's code runs on one of a fixed pool of Workers. Its state stays with the agent that owns the directory.
// classes.js import { DurableWorker } from "runtime:workers"; export class Cart extends DurableWorker { /* … */ } // main.js import { configure } from "runtime:workers"; import { Cart } from "./classes.js"; configure({ shards: "auto", module: new URL("./classes.js", import.meta.url) }); await Cart.get("c1").add("book"); // runs on a shard
Each shard imports
moduleand runs the classes it exports. Any other class is refused at its first call with aTypeError. A shard also runs the module's top-level code, so keep the classes in a module of their own.A worker is placed by a hash of its class and id, so it always lands on the same shard.
The API does not change. Reads stay synchronous, and the gate, ceilings, mailbox, collections, transactions and alarms all behave as they do without shards.
A shard holds
importsand whateverpermissionsnames. It needs noreadorwritegrant for its state.A shard that throws uncaught, runs out of memory, or does not return to its event loop for about 3 seconds is terminated. The call in flight rejects with
ERR_DURABLE_SHARD_LOST; the worker comes back on its next call.An idle shard does not keep the process alive.
Starting shards needs --allow-workers and --allow-imports. startAlarms() runs on the owning agent and throws inside a shard.
shutdown()
Closes every open worker — flushing, running stop() — and releases the directory. Results are gated on their writes, so an abrupt exit loses nothing that was acknowledged; shutdown() is how a process asks to stop, which is what gives stop() a chance to run.
It installs no signal handler, because doing that switches off esrun's own HTTP drain. An application that wants both wires them together:
import { exit, onSignal } from "runtime:process"; import { shutdown } from "runtime:workers"; onSignal("SIGTERM", async () => { await server.stop(); await shutdown(); exit(143); });
Errors
| Code | Meaning |
|---|---|
ERR_DURABLE_LOCKED | Another process has this directory open. |
ERR_DURABLE_BUSY | The mailbox is full — more calls waiting than mailbox allows. |
ERR_DURABLE_STATE_TOO_LARGE | A value, or a worker's whole state, is over the limit. |
ERR_DURABLE_STATE_FORMAT | State written by a newer runtime, in a format this build cannot read. |
ERR_DURABLE_SHUTDOWN | Shutting down, or that worker has been closed. |
ERR_DURABLE_SHARD_LOST | The shard running the worker ended while a call was in flight. |
ERR_DURABLE_CYCLE | The call would close a loop of workers waiting on each other; the message names it. |