Durable Objects

Durable Objects (DOs) give you stateful, single-instance compute on Cloudflare’s network. Each DO is globally unique, identified by an ID, and guaranteed to run as a single instance at a time. This makes them the building block for real-time features, coordination, and anything that needs strong consistency without a traditional database.

Prerequisites: First Worker, Storage Landscape

What Durable Objects Are

A Durable Object is a JavaScript class that:

  • Has a globally unique identity (by name or random ID)
  • Runs as a single instance - no concurrent instances of the same ID
  • Has private, persistent storage (key-value API or SQLite)
  • Can hold WebSocket connections and survive hibernation
  • Lives on the edge, colocated with its first caller

Think of it as a tiny server dedicated to one entity: a chat room, a rate limiter, a user session, a game lobby.

DO Lifecycle

stateDiagram-v2
    [*] --> Idle: stub.get(id)
    Idle --> Instantiated: First request
    Instantiated --> Handling: Incoming fetch/alarm
    Handling --> Handling: More requests
    Handling --> Hibernating: No active work
    Hibernating --> Handling: New request or alarm
    Hibernating --> Evicted: Idle timeout
    Evicted --> Instantiated: Next request
    Evicted --> [*]

Key transitions:

  • Instantiated: the runtime creates your class instance and calls constructor(state, env)
  • Handling: your fetch() or alarm() method processes a request
  • Hibernating: the DO has WebSocket connections but no active CPU work; you only pay for connections, not CPU
  • Evicted: after prolonged inactivity, the runtime destroys the instance; storage persists, the object re-instantiates on the next request

Accessing a Durable Object

From a Worker, you get a “stub” (proxy) and call fetch() on it:

// In your Worker
app.get("/room/:name", async (c) => {
  const id = c.env.CHAT_ROOM.idFromName(c.req.param("name"));
  const stub = c.env.CHAT_ROOM.get(id);
  return stub.fetch(c.req.raw);
});

Two ways to get an ID:

  • idFromName(name) - deterministic; same name always gives the same ID (good for named entities like chat rooms)
  • newUniqueId() - random; guaranteed unique (good for sessions, temporary state)

Storage API

Each DO gets private storage that survives eviction. Two options:

Key-Value Storage

Simple get/put/delete with automatic serialization:

export class Counter implements DurableObject {
  constructor(private state: DurableObjectState, private env: Env) {}

  async fetch(request: Request): Promise<Response> {
    const current = (await this.state.storage.get<number>("count")) ?? 0;
    const next = current + 1;
    await this.state.storage.put("count", next);
    return new Response(JSON.stringify({ count: next }));
  }
}

Key-value operations:

MethodDescription
get(key)Read a single value
get(keys[])Read multiple values (returns Map)
put(key, value)Write a single value
put(entries)Write multiple key-value pairs atomically
delete(key)Delete a single key
deleteAll()Wipe all storage
list()List all keys (with optional prefix/range)

SQLite Storage

For complex queries, use the built-in SQLite database. Enable it in wrangler.jsonc:

{
  "durable_objects": {
    "bindings": [
      {
        "name": "CHAT_ROOM",
        "class_name": "ChatRoom",
        "sqlite_database": true
      }
    ]
  }
}

Then use this.state.storage.sql:

export class ChatRoom implements DurableObject {
  private sql: SqlStorage;

  constructor(private state: DurableObjectState, private env: Env) {
    this.sql = state.storage.sql;
    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        sender TEXT NOT NULL,
        content TEXT NOT NULL,
        sent_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/messages") {
      const rows = this.sql
        .exec("SELECT * FROM messages ORDER BY sent_at DESC LIMIT 50")
        .toArray();
      return Response.json(rows);
    }

    if (url.pathname === "/send" && request.method === "POST") {
      const { sender, content } = await request.json<{
        sender: string;
        content: string;
      }>();
      this.sql.exec(
        "INSERT INTO messages (sender, content) VALUES (?, ?)",
        sender,
        content
      );
      return Response.json({ ok: true });
    }

    return new Response("Not found", { status: 404 });
  }
}

Gotcha: SQLite in DOs is synchronous (exec not prepare(...).run() like D1). This is because DO storage is local to the instance, not over the network. The API is different from D1.

WebSocket Hibernation

The killer feature. A DO can hold thousands of WebSocket connections while hibernating, meaning you only pay for CPU when messages actually arrive.

How It Works

flowchart LR
    subgraph client["Clients"]
        C1["Dashboard 1"]
        C2["Dashboard 2"]
        C3["Dashboard 3"]
    end

    subgraph worker["Worker"]
        W["Route Handler"]
    end

    subgraph durableObject["Durable Object"]
        DO["WebSocket Handler"]
        S[("SQLite")]
    end

    C1 -->|WebSocket| W
    C2 -->|WebSocket| W
    C3 -->|WebSocket| W
    W -->|"stub.fetch(upgrade)"| DO
    DO --- S

    style durableObject fill:#e8f4e8

Instead of the standard addEventListener("message", ...) pattern, you use the Hibernation API:

  1. Accept the WebSocket with this.state.acceptWebSocket(ws)
  2. The runtime calls webSocketMessage(), webSocketClose(), etc. on your class
  3. Between messages, the DO hibernates - no CPU charges

WebSocket Lifecycle

export class LiveDashboard implements DurableObject {
  private sql: SqlStorage;

  constructor(private state: DurableObjectState, private env: Env) {
    this.sql = state.storage.sql;
    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS events (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        type TEXT NOT NULL,
        data TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    // WebSocket upgrade
    if (url.pathname === "/ws") {
      if (request.headers.get("Upgrade") !== "websocket") {
        return new Response("Expected WebSocket", { status: 426 });
      }

      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);

      // Tag the socket for later filtering
      const tag = url.searchParams.get("source") ?? "all";
      this.state.acceptWebSocket(server, [tag]);

      return new Response(null, { status: 101, webSocket: client });
    }

    // HTTP endpoint to push events (called by the queue consumer)
    if (url.pathname === "/event" && request.method === "POST") {
      const event = await request.json<{ type: string; data: string }>();
      this.sql.exec(
        "INSERT INTO events (type, data) VALUES (?, ?)",
        event.type,
        event.data
      );
      this.broadcast(JSON.stringify(event));
      return Response.json({ ok: true });
    }

    // Recent events via HTTP
    if (url.pathname === "/events") {
      const rows = this.sql
        .exec("SELECT * FROM events ORDER BY created_at DESC LIMIT 100")
        .toArray();
      return Response.json(rows);
    }

    return new Response("Not found", { status: 404 });
  }

  // Hibernation API callbacks - called when the DO wakes up

  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
    // Handle incoming messages from clients
    try {
      const data = JSON.parse(message as string);
      if (data.type === "ping") {
        ws.send(JSON.stringify({ type: "pong" }));
      }
    } catch {
      ws.send(JSON.stringify({ error: "invalid message" }));
    }
  }

  webSocketClose(ws: WebSocket, code: number, reason: string): void {
    ws.close(code, reason);
  }

  webSocketError(ws: WebSocket, error: unknown): void {
    console.error("WebSocket error:", error);
    ws.close(1011, "Internal error");
  }

  private broadcast(message: string, filterTag?: string): void {
    const sockets = filterTag
      ? this.state.getWebSockets(filterTag)
      : this.state.getWebSockets();

    for (const ws of sockets) {
      try {
        ws.send(message);
      } catch {
        // Socket already closed, will be cleaned up by webSocketClose
      }
    }
  }
}

Key Hibernation API methods:

MethodDescription
state.acceptWebSocket(ws, tags?)Register a WebSocket for hibernation
state.getWebSockets(tag?)Get all connected WebSockets (optionally filtered by tag)
webSocketMessage(ws, msg)Called when a message arrives
webSocketClose(ws, code, reason)Called when a socket closes
webSocketError(ws, error)Called on socket errors

Gotcha: DOs are single-threaded. One request at a time per instance. Concurrent requests queue up via “input gates” - the runtime serializes them automatically. This is a feature, not a bug: it means you never have race conditions on your storage.

Alarm API

Schedule a DO to wake up at a specific time. Useful for deferred work, cleanup, or periodic tasks within a single DO.

export class ExpiringSession implements DurableObject {
  constructor(private state: DurableObjectState, private env: Env) {}

  async fetch(request: Request): Promise<Response> {
    // Set an alarm to expire this session in 30 minutes
    const thirtyMinutes = 30 * 60 * 1000;
    await this.state.storage.setAlarm(Date.now() + thirtyMinutes);

    await this.state.storage.put("lastAccess", Date.now());
    return new Response("Session refreshed");
  }

  async alarm(): Promise<void> {
    const lastAccess = await this.state.storage.get<number>("lastAccess");
    const thirtyMinutes = 30 * 60 * 1000;

    if (lastAccess && Date.now() - lastAccess < thirtyMinutes) {
      // Session was accessed recently, reschedule
      await this.state.storage.setAlarm(Date.now() + thirtyMinutes);
      return;
    }

    // Session expired, clean up
    await this.state.storage.deleteAll();
  }
}

Rules:

  • One alarm per DO at a time (setting a new one replaces the old one)
  • Alarms survive hibernation and eviction
  • Minimum granularity is about 1 second
  • Use state.storage.getAlarm() to check if one is set
  • Use state.storage.deleteAlarm() to cancel

Input Gates and Output Gates

DOs have a consistency model built on two concepts:

Input gates serialize concurrent requests. If three requests arrive simultaneously, they execute one at a time. This prevents race conditions on storage without locks.

Output gates prevent the DO from accepting new input while a fetch() to an external service is in flight and the response has not yet been processed. This ensures that if your code does await fetch(external) followed by a storage write, no other request can interleave between the fetch response and the write.

Together, these give you effectively serializable isolation:

// This is safe without locks because of input gates
async fetch(request: Request): Promise<Response> {
  const balance = await this.state.storage.get<number>("balance") ?? 0;
  // No other request can read/write between these two lines
  await this.state.storage.put("balance", balance - 10);
  return new Response(`New balance: ${balance - 10}`);
}

Patterns

Chat Room

One DO per room. WebSocket hibernation handles connections, SQLite stores messages:

const roomId = env.CHAT_ROOM.idFromName("general");
const room = env.CHAT_ROOM.get(roomId);
// Clients connect via WebSocket, DO broadcasts to all

Rate Limiter

One DO per API key. Strongly consistent counter without race conditions:

const limiterId = env.RATE_LIMITER.idFromName(apiKey);
const limiter = env.RATE_LIMITER.get(limiterId);
const res = await limiter.fetch(new Request("http://internal/check"));
const { allowed } = await res.json();

Distributed Lock

One DO per resource. The input gate serialization gives you lock semantics for free:

const lockId = env.LOCK.idFromName(resourceId);
const lock = env.LOCK.get(lockId);
// All requests serialize, so the first one "holds" the lock

Session State

One DO per user session. Alarm API handles expiration:

const sessionId = env.SESSION.idFromName(userId);
const session = env.SESSION.get(sessionId);
// Session data persists; alarm cleans up after inactivity

Webhook Hub: Real-Time Delivery Dashboard

Here is the complete DO that upgrades the full-stack app polling dashboard to real-time WebSocket updates. When the queue consumer delivers a webhook, it posts the result to this DO, which broadcasts to all connected dashboard clients.

wrangler.jsonc additions

{
  "durable_objects": {
    "bindings": [
      {
        "name": "DELIVERY_TRACKER",
        "class_name": "DeliveryTracker",
        "sqlite_database": true
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["DeliveryTracker"]
    }
  ]
}

Complete DO class

// src/delivery-tracker.ts
export class DeliveryTracker implements DurableObject {
  private sql: SqlStorage;

  constructor(private state: DurableObjectState, private env: Env) {
    this.sql = state.storage.sql;
    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS delivery_events (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        webhook_id INTEGER NOT NULL,
        target_url TEXT NOT NULL,
        status TEXT NOT NULL,
        error TEXT,
        timestamp TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);

    // Set up a cleanup alarm every hour
    state.storage.setAlarm(Date.now() + 60 * 60 * 1000);
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    // WebSocket upgrade for dashboard clients
    if (url.pathname === "/ws") {
      if (request.headers.get("Upgrade") !== "websocket") {
        return new Response("Expected WebSocket", { status: 426 });
      }

      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);

      this.state.acceptWebSocket(server);

      // Send recent events on connect
      const recent = this.sql
        .exec(
          "SELECT * FROM delivery_events ORDER BY timestamp DESC LIMIT 20"
        )
        .toArray();
      server.send(JSON.stringify({ type: "history", events: recent }));

      return new Response(null, { status: 101, webSocket: client });
    }

    // Delivery result posted by the queue consumer
    if (url.pathname === "/delivery" && request.method === "POST") {
      const event = await request.json<{
        webhookId: number;
        targetUrl: string;
        status: "delivered" | "failed" | "dead_letter";
        error?: string;
      }>();

      this.sql.exec(
        "INSERT INTO delivery_events (webhook_id, target_url, status, error) VALUES (?, ?, ?, ?)",
        event.webhookId,
        event.targetUrl,
        event.status,
        event.error ?? null
      );

      // Broadcast to all connected dashboards
      const message = JSON.stringify({ type: "delivery", event });
      for (const ws of this.state.getWebSockets()) {
        try {
          ws.send(message);
        } catch {
          // closed socket, webSocketClose will clean up
        }
      }

      return Response.json({ ok: true });
    }

    // HTTP fallback: recent events
    if (url.pathname === "/events") {
      const rows = this.sql
        .exec(
          "SELECT * FROM delivery_events ORDER BY timestamp DESC LIMIT 100"
        )
        .toArray();
      return Response.json(rows);
    }

    return new Response("Not found", { status: 404 });
  }

  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
    try {
      const data = JSON.parse(message as string);
      if (data.type === "ping") {
        ws.send(JSON.stringify({ type: "pong" }));
      }
    } catch {
      ws.send(JSON.stringify({ error: "invalid message" }));
    }
  }

  webSocketClose(ws: WebSocket, code: number, reason: string): void {
    ws.close(code, reason);
  }

  webSocketError(ws: WebSocket, error: unknown): void {
    ws.close(1011, "WebSocket error");
  }

  async alarm(): Promise<void> {
    // Clean up events older than 24 hours
    this.sql.exec(
      "DELETE FROM delivery_events WHERE timestamp < datetime('now', '-1 day')"
    );
    // Reschedule
    await this.state.storage.setAlarm(Date.now() + 60 * 60 * 1000);
  }
}

Connecting the queue consumer

Update the queue consumer from Queues & Async Processing to post delivery results to the DO:

async queue(batch: MessageBatch<DeliveryJob>, env: Env): Promise<void> {
  // Use a single DO instance for the delivery tracker
  const trackerId = env.DELIVERY_TRACKER.idFromName("global");
  const tracker = env.DELIVERY_TRACKER.get(trackerId);

  for (const msg of batch.messages) {
    const { webhookId, targetUrl, payload } = msg.body;

    try {
      const response = await fetch(targetUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: payload,
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      // Notify the real-time tracker
      await tracker.fetch(new Request("http://internal/delivery", {
        method: "POST",
        body: JSON.stringify({
          webhookId,
          targetUrl,
          status: "delivered",
        }),
      }));

      msg.ack();
    } catch (err) {
      await tracker.fetch(new Request("http://internal/delivery", {
        method: "POST",
        body: JSON.stringify({
          webhookId,
          targetUrl,
          status: "failed",
          error: String(err),
        }),
      }));

      msg.retry();
    }
  }
}

Dashboard WebSocket client

Add a WebSocket connection to the React dashboard from Full-Stack App:

useEffect(() => {
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
  const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    if (data.type === "delivery") {
      // Update delivery status in the UI
      setDeliveries((prev) => [data.event, ...prev].slice(0, 50));
    }
    if (data.type === "history") {
      setDeliveries(data.events);
    }
  };

  ws.onclose = () => {
    // Reconnect after 3 seconds
    setTimeout(() => window.location.reload(), 3000);
  };

  return () => ws.close();
}, []);

Route the WebSocket through the Worker to the DO:

// In the Hono API
app.get("/api/ws", async (c) => {
  const id = c.env.DELIVERY_TRACKER.idFromName("global");
  const stub = c.env.DELIVERY_TRACKER.get(id);
  // Forward the upgrade request to the DO
  return stub.fetch(new Request(new URL("/ws", c.req.url), c.req.raw));
});

Cost Considerations

ComponentIncluded (Paid Plan)Overage
Requests1M/month$0.15/M
Duration400K GB-s/month$12.50/M GB-s
Storage (KV)1GB$0.20/GB/month
Storage (SQLite)5GB$0.75/GB/month

During WebSocket hibernation, you pay for storage and connections but not CPU duration. A DO with 1,000 idle WebSocket connections costs almost nothing until messages flow.

Gotcha: DOs are not replicated or load-balanced. Each DO ID runs on exactly one server. If that data center has an issue, the DO is unavailable until failover (usually seconds). Design for this by keeping DOs stateless where possible and using D1 as the source of truth.