Heximon Logo
Realtime

Durable Objects

Author a single-threaded stateful actor at the edge with DurableObject and a DI-injected DurableFactory, address it by name, and reach storage through DurableContext.

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
vite.config.ts
import heximon from "@heximon/build/vite";
import { defineConfig } from "vite-plus";

export default defineConfig({
  plugins: [heximon()],
  server: { port: 3000 },
});
heximon.config.ts
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.)

src/counters/counter.ts
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.

src/counters/counter.factory.ts
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:

src/counters/counters.controller.ts
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.

src/counters/counters.module.ts
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.

src/counters/counter.repository.ts
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();
The escape cast is the visible boundary where you leave the portable surface. The Node dev shim does not emulate .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:

src/sessions/session.ts
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
terminal
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.

OptionDefaultWhat it does
storage"kv""sql" (SQLite) or "kv" (key-value) backing for the object's persistent state
bindingSCREAMING_SNAKE of the class nameThe Workers binding name the object is bound under
hibernatefalseRequired 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:

heximon.config.ts
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 RateLimitStore port and how @heximon/durable/security drops in for MemoryRateLimitStore, 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.json binding rules, and the full Workers deploy.
  • the flagship Cloudflare app — a hibernate: true seat-map room hosting a WebSocket, plus a keyed feed room fanning out SSE across isolates, all addressed through DurableFactory handles.
Copyright © 2026