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, so what may be passed is the same rule it will be when a worker runs on a shard of its own. 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.
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.
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 | An alarm that failed for the last time, or a worker that could not be opened. 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, inside the working directory |
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 |
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. |
Not yet
Shards: a worker on a Worker of its own, with a watchdog and a memory ceiling. It arrives with its own phase rather than as an option that does nothing today.