Durable Objects
Durable Objects are Cloudflare-specific — there's no
equivalent to deploy on Node, Vercel, AWS Lambda, or Netlify. pnpm dev runs a faithful Node shim so the same
code path runs in dev and tests, but a production Durable Object only ever runs on Cloudflare Workers.
Most realtime apps never need one. A single Node server already holds every connection in one process, so plain WebSockets or Server-Sent Events cover chat, presence, and live feeds with no extra concept.
Reach for a Durable Object only when your app deploys to Cloudflare and clients of the same room or topic
can land on different isolates — at that point one isolate's publish can't reach a connection held by
another, and a Durable Object gives that room a single, addressable, stateful home.
Heximon lets you write one as an ordinary injectable class: a DurableObject (the actor) plus a
DurableFactory<Counter> (the handle you call it through). Your constructor takes injected dependencies, never
the Workers (state, env) pair — so a Durable Object stays a plain Heximon class, testable like every other
provider.
Set up the plugin
Install the package and list DurablePlugin alongside HTTP. (See Controllers for the
full install + register walkthrough every transport shares.)
pnpm add @heximon/durable
npm install @heximon/durable
yarn add @heximon/durable
bun add @heximon/durable
import heximon from "@heximon/build/vite";
import { defineConfig } from "vite-plus";
export default defineConfig({
plugins: [heximon()],
server: { port: 3000 },
});
import { defineHeximonConfig } from "@heximon/build";
import { DurablePlugin } from "@heximon/durable/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
export default defineHeximonConfig({ plugins: [new HttpPlugin(), new DurablePlugin()] });
For a production build, add new DurablePlugin() to the heximon.config.ts config file's plugins
array — Nitro and Nuxt read the same config file.
The concept: one addressable instance per id
A Durable Object is a single-threaded, stateful actor at the edge — one addressable instance per id, with persistent storage and no concurrency to reason about, because every call to a given object runs serially. Two requests to the same object queue behind each other, so a read-modify-write needs no lock; requests to different ids run fully in parallel across the platform.
Author the actor
A DurableObject is your single-instance actor. Its public, non-lifecycle methods are the RPC
surface callers invoke over the network, and its constructor declares injected dependencies like any
provider. (extends DurableObject<...> binds identically — use it when you want your own base-class
hierarchy on top of an actor.)
import type { DurableObject } from "@heximon/durable";
import { CounterRepository } from "./counter.repository";
export class Counter implements DurableObject<{ storage: "sql" }> {
constructor(private readonly repo: CounterRepository) {}
public async increment(by = 1): Promise<number> {
return this.repo.increment("count", by);
}
}
Choose "sql" for SQLite-backed storage or "kv" (the default) for key-value.
Address it through a factory
You never construct a Durable Object directly; you call it through a DurableFactory<Counter>. Extend it
with an empty body and the compiler registers the factory as a provider for you, bound to the object
named in the extends DurableFactory<...> type argument.
import { DurableFactory } from "@heximon/durable";
import { Counter } from "./counter";
export class CounterFactory extends DurableFactory<Counter> {}
Inject the factory anywhere, then resolve an object and call its RPC methods. getByName derives a
stable id from a human-readable name:
import type { Controller, Post } from "@heximon/http";
import { CounterFactory } from "./counter.factory";
export class CountersController implements Controller<"/counters"> {
constructor(private readonly counters: CounterFactory) {}
public async bump(action: Post<"/:id/bump">): Promise<{ count: number }> {
// `id` is a human name → resolve with getByName, then call the RPC method.
const count = await this.counters.getByName(action.request.pathParams.id).increment(1);
return { count };
}
}
The lower-level factory.get(id) takes a DurableObjectId, not a string — passing a bare string is a
compile error, so reach for getByName whenever your key is a name.
Never list a DurableFactory in providers — the compiler already registers it, and a providers
entry would shadow that binding. List it only under durable: { factories }, as the module below shows.
Wire the module
Each concept lives under the namespace its plugin owns: the object and its factory under durable, the
controller under http, and the plain CounterRepository in providers like any injectable.
import { Module } from "@heximon/runtime";
import { Counter } from "./counter";
import { CounterFactory } from "./counter.factory";
import { CounterRepository } from "./counter.repository";
import { CountersController } from "./counters.controller";
export class CountersModule extends Module({
providers: [CounterRepository],
http: { controllers: [CountersController] },
durable: { objects: [Counter], factories: [CounterFactory] },
}) {}
Reach storage and id
A Durable Object reads its own storage and id by injecting the DurableContext token — the
framework-owned handle to that instance's per-object state. Inject it into the object itself, or into a
provider the object depends on.
import { DurableContext } from "@heximon/durable";
export class CounterRepository {
constructor(private readonly context: DurableContext) {}
public async increment(key: string, by: number): Promise<number> {
const current = (await this.context.storage.get<number>(key)) ?? 0;
const next = current + by;
await this.context.storage.put(key, next);
return next;
}
}
context.storage is the portable DurableStorage surface — get / put / delete and a full list()
snapshot — the subset that both Cloudflare's storage and the Node dev shim actually support, so it works the
same in pnpm dev and on Workers. For Cloudflare-only surfaces (sql, transaction, alarms, or paginated
list(options)) cast to the full DurableObjectStorage, which your app imports from its own
@cloudflare/workers-types dependency:
import type { DurableObjectStorage } from "@cloudflare/workers-types";
const rows = (this.context.storage as unknown as DurableObjectStorage).sql
.exec("SELECT * FROM events")
.toArray();
.sql — a "sql"-backed object reaching for it works under wrangler dev / Workers but throws on the
Node shim, where SQLite is not run in-process.Because Durable Objects are lazily constructed — the framework never builds a DO in the main app,
only in the per-instance bridge that supplies a bound DurableContext — it is safe to read
context.storage or context.id directly in a constructor:
import { DurableContext, type DurableObject } from "@heximon/durable";
export class Session implements DurableObject<{ storage: "kv" }> {
public readonly sessionId: string;
constructor(private readonly context: DurableContext) {
this.sessionId = context.id.toString(); // safe — only the bridge constructs this, never the main app
}
}
DurableContext only resolves inside a Durable Object instance. Inject it into a plain controller
and the first access throws DurableContext is only available inside a Durable Object instance — the throw
catches a misplaced injection before it reads the wrong object's state. Keep it confined to a Durable
Object and the providers it owns.Run it on Node
pnpm dev runs a faithful Node shim, not real Durable Objects: Map-backed storage and an in-process
namespace that keeps one instance per id. You exercise the full RPC path locally before you ship.
pnpm dev
npm run dev
yarn run dev
bun run dev
curl -X POST http://localhost:3000/counters/hello/bump
# → { "count": 1 } (call it again → { "count": 2 } — same object, persisted state)
The shim runs serially the same way the platform does, so the single-threaded guarantee holds in dev too. What it can't reproduce is cross-isolate behavior — for that, deploy.
Configure the object
Options are passed as the DurableObject<{...}> TYPE argument and tune storage and the generated Workers binding.
| Option | Default | What it does |
|---|---|---|
storage | "kv" | "sql" (SQLite) or "kv" (key-value) backing for the object's persistent state |
binding | SCREAMING_SNAKE of the class name | The Workers binding name the object is bound under |
hibernate | false | Required to host a WebSocket handler in the object via the Hibernation API (see WebSockets) |
Hibernation keeps an idle room free
Setting hibernate: true lets Cloudflare evict the object between frames and rehydrate it on the next one, so
a room with no traffic costs nothing while it still holds every live connection for that topic.
This is what makes a Durable Object the cross-isolate broadcast tier for WebSockets
and Server-Sent Events: host the socket or stream inside the object
instead of a plain handler, and publish/broadcast reaches every connection in the room regardless of which
isolate accepted it.
Deploy to Cloudflare Workers
On Cloudflare, each object runs as a real Durable Object, and CloudflareWorkerPlugin generates the worker
entry for you — no worker file to hand-write. DurablePlugin also emits the Durable Object bindings and
migration ledger into the wrangler.json the CloudflarePlugin writes. See
Cloudflare for the generated bridge, the wrangler.json binding
rules, and the full Workers deploy.
Adding an object in a later release? Tag it — DurableObject<{ migration: "v2" }> — so Cloudflare registers
only the new class in its own ordered migration.
Renaming or deleting an object names a class that's gone from source, so the compiler can't derive it;
declare those in heximon.config.ts under the app-level durable.migrations override (renamedClasses /
deletedClasses, by generated <Name>DO class name), and they fold into the same ordered ledger:
import { defineHeximonConfig } from "@heximon/build";
import { DurablePlugin } from "@heximon/durable/compiler";
export default defineHeximonConfig({
plugins: [new DurablePlugin() /* … */],
durable: {
migrations: [
{ tag: "v3", renamedClasses: [{ from: "ChatRoomDO", to: "RoomV2DO" }] },
{ tag: "v4", deletedClasses: ["LegacyRoomDO"] },
],
},
});
Distributed rate limiting (@heximon/durable/security)
@heximon/durable/security ships a turnkey rate-limit tier backed by a Durable Object — one DO per
rate-limit key — so the sliding-window counter is shared across all isolates.
The package ships the RateLimitRoom Durable Object, its RateLimitRoomFactory handle, and the concrete
RateLimitRoomStore (a transparent drop-in for MemoryRateLimitStore), so the app lists them with no
local Durable Object shells: RateLimitRoom + RateLimitRoomFactory under durable, RateLimitRoomStore
under providers.
The RateLimitMiddleware, its policy, and the controller are unchanged.
See Security hardening → Distributed rate limiting for the
full wiring pattern.
Strongly-consistent key/value storage (@heximon/durable/kv)
@heximon/durable/kv ships a turnkey Storage (the key/value port)
backed by a Durable Object. All keys live in one single-threaded room, so every read and write —
including the conditional setIfAbsent — is strongly consistent and atomic across all isolates
and regions. That is exactly what the idempotency guard needs on
Cloudflare, where the eventually-consistent Workers KV is the wrong store.
The package ships the KvRoom Durable Object, its KvRoomFactory handle, and the concrete
KvRoomStorage (a satisfier of the Storage DI token), so the app lists them with no local
Durable Object shells:
import { Module } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { KvRoom, KvRoomFactory, KvRoomStorage } from "@heximon/durable/kv";
export class StorageModule extends Module({
providers: [{ provide: Storage, useClass: KvRoomStorage }],
durable: {
objects: [KvRoom],
factories: [KvRoomFactory],
},
exports: [Storage],
}) {}
TTL is honored with lazy expiry inside the room (no alarms). A room sustains ~500–1,000 req/s —
plenty for idempotency markers and coordination state, wrong for a high-QPS cache tier (keep
CloudflareKvStorage
there). For an isolated or hotter keyspace, construct the DurableObjectKvStorage routing base with
{ room, shards } — keys hash deterministically onto the fixed shard set, so single-key semantics
stay exact at any shard count.
See also
- Realtime overview — plain WebSockets and SSE cover most apps with no Durable Object at all.
- Security hardening — the
RateLimitStoreport and how@heximon/durable/securitydrops in forMemoryRateLimitStore, plus the distributed rate-limit store wired alongside the body guard and security headers. - WebSockets — host a WebSocket handler inside a Durable Object with
hibernate: true, for real cross-isolate fan-out via the Hibernation API. - Server-Sent Events — broadcast typed server-sent events across isolates from a Durable Object holding the live streams.
- Cloudflare — the generated bridge,
wrangler.jsonbinding rules, and the full Workers deploy. - the flagship Cloudflare app
— a
hibernate: trueseat-map room hosting a WebSocket, plus a keyed feed room fanning out SSE across isolates, all addressed throughDurableFactoryhandles.
AsyncAPI
Generate an AsyncAPI 3.1 document from your WebSocket contracts and SSE event maps at runtime with AsyncApiGenerator, serving bidirectional WS send/receive operations and SSE receive-only operations, with the wire envelope documented at the message level.
Overview
PlatformStrategy, heximon.platform, vp build artifacts, feature x platform support matrix, NodeHostStrategy, CloudflareWorkersStrategy, VercelFunctionsStrategy, NetlifyFunctionsStrategy, LambdaStrategy, DenoDeployStrategy, heximon deploy.