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.

Capability: FileRead / FileWrite

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

JavaScript
import { DurableWorker, configure, startAlarms, shutdown } from "runtime:workers";
JavaScript
// 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") ?? [];
  }
}
JavaScript
// 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());
});
A DurableWorker is not a Worker

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 timeCalls to one worker queue in its mailbox and run in order — no lock to take, no race to lose.
Reads are synchronousState is resident in the worker's heap, so state.get(k) is a map lookup, not an await.
Writes are gatedA call's result is not handed back until the writes it made have committed.
The runtime owns the schemaNo DDL, no SQL, no migration script.
One process per directoryA second is refused with ERR_DURABLE_LOCKED until the first exits.

Exports

ExportTypeDescription
DurableWorkerclassThe base class. Extend it.
configurefunctionconfigure(options?) — settings, before the first worker is used.
startAlarmsfunctionstartAlarms({ classes }) — begins servicing alarms in this process.
shutdownasync functionCloses every open worker and releases the directory.
DurableError, DurableErrorCodeclass, objectThe failures, and their stable codes.

The class

Member
this.stateIts key/value state — see below
this.idThe 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 durableNameThe 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, bytesSynchronous.
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.

The gate covers the result, not the world

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.

JavaScript
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.

Only declared fields are queryable

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.

JavaScript
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:

JavaScript
import { startAlarms } from "runtime:workers";
import { Reminder } from "./workers.js";

const alarms = startAlarms({ classes: [Reminder] });
await alarms.stop();
classes is required

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
classesRequired. The DurableWorker subclasses this process runs alarms for.
onErrorAn alarm that failed for the last time, or a worker that could not be opened. Defaults to console.error.
batchHow 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.

OptionDefault
dir"./.durable"Where state lives, inside the working directory
evictAfter30000How long a worker may idle before it is closed
maxLive128How many may be open at once
mailbox1024How many calls may wait on one worker
stateLimit / valueLimit1 MiB / 128 KiBThe ceilings above
alarmRetries5How many times a failing alarm() is retried
alarmPoll60000The 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:

JavaScript
import { exit, onSignal } from "runtime:process";
import { shutdown } from "runtime:workers";

onSignal("SIGTERM", async () => {
  await server.stop();
  await shutdown();
  exit(143);
});

Errors

CodeMeaning
ERR_DURABLE_LOCKEDAnother process has this directory open.
ERR_DURABLE_BUSYThe mailbox is full — more calls waiting than mailbox allows.
ERR_DURABLE_STATE_TOO_LARGEA value, or a worker's whole state, is over the limit.
ERR_DURABLE_STATE_FORMATState written by a newer runtime, in a format this build cannot read.
ERR_DURABLE_SHUTDOWNShutting 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.

Last updated on
Edit this page