Skip to main content
Technology & EngineeringCloudflare Workers406 lines

Durable Objects

Cloudflare Durable Objects for stateful edge computing, covering constructor patterns, storage API, WebSocket support, alarm handlers, consistency guarantees, and use cases like rate limiting, collaboration, and game state.

Quick Summary15 lines
You are an expert in Cloudflare Durable Objects, a primitive for strongly consistent, stateful computation at the edge. Each Durable Object is a single-threaded, globally unique instance with its own persistent storage and the ability to coordinate between requests.

## Key Points

- **Global uniqueness**: Only one instance of a Durable Object with a given ID exists at any time.
- **Single-threaded**: Requests to the same object are serialized (no concurrent access to storage).
- **Strong consistency**: Reads always see the latest writes within the same object.
- **Transactional storage**: Multiple reads and writes within a single `storage.transaction()` are atomic.
- **Location affinity**: The object runs near the first client that creates it, and stays there.
- **"Durable Object storage operation exceeded timeout"** — Keep transactions short. Avoid heavy computation inside `storage.transaction()`.
- **"Durable Object has been evicted"** — Objects are evicted after ~10 seconds of inactivity. In-memory state is lost. Use `blockConcurrencyWhile` to reload state.
- **High latency** — The object runs in one location. Clients far away see higher latency. Consider using KV for read caching on top of Durable Objects for writes.
- **Migration errors** — Ensure the `[[migrations]]` tag in `wrangler.toml` is updated when adding, renaming, or removing Durable Object classes.
skilldb get cloudflare-workers-skills/Durable ObjectsFull skill: 406 lines
Paste into your CLAUDE.md or agent config

Durable Objects — Cloudflare Workers

You are an expert in Cloudflare Durable Objects, a primitive for strongly consistent, stateful computation at the edge. Each Durable Object is a single-threaded, globally unique instance with its own persistent storage and the ability to coordinate between requests.

Core Philosophy

Overview

Durable Objects solve the problem that Workers alone cannot: coordination and strong consistency. Each Durable Object has a unique ID, runs in a single location, processes requests one at a time (single-threaded), and has transactional storage. This makes them ideal for rate limiting, collaborative editing, chat rooms, game state, counters, and any workload requiring serialized access to shared state.

Key guarantees

  • Global uniqueness: Only one instance of a Durable Object with a given ID exists at any time.
  • Single-threaded: Requests to the same object are serialized (no concurrent access to storage).
  • Strong consistency: Reads always see the latest writes within the same object.
  • Transactional storage: Multiple reads and writes within a single storage.transaction() are atomic.
  • Location affinity: The object runs near the first client that creates it, and stays there.

Setup

wrangler.toml

[durable_objects]
bindings = [
  { name = "COUNTER", class_name = "Counter" },
  { name = "CHAT_ROOM", class_name = "ChatRoom" },
]

[[migrations]]
tag = "v1"
new_classes = ["Counter", "ChatRoom"]

Durable Object class

export interface Env {
  COUNTER: DurableObjectNamespace;
  CHAT_ROOM: DurableObjectNamespace;
}

export class Counter implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

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

    if (url.pathname === "/increment") {
      let count = (await this.state.storage.get<number>("count")) || 0;
      count++;
      await this.state.storage.put("count", count);
      return Response.json({ count });
    }

    if (url.pathname === "/get") {
      const count = (await this.state.storage.get<number>("count")) || 0;
      return Response.json({ count });
    }

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

Calling a Durable Object from a Worker

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Get a Durable Object by name (creates a deterministic ID)
    const id = env.COUNTER.idFromName("global-counter");

    // Or generate a random unique ID
    // const id = env.COUNTER.newUniqueId();

    // Or reconstruct from a stored hex string
    // const id = env.COUNTER.idFromString("abc123...");

    // Get a stub (reference) to the object
    const stub = env.COUNTER.get(id);

    // Forward the request to the Durable Object
    return stub.fetch(request);
  },
};

Storage API

Basic key-value operations

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

  async fetch(request: Request): Promise<Response> {
    const storage = this.state.storage;

    // Put a single value
    await storage.put("key", "value");

    // Put multiple values atomically
    await storage.put(new Map([
      ["user:name", "Alice"],
      ["user:email", "alice@example.com"],
      ["user:role", "admin"],
    ]));

    // Get a single value
    const name = await storage.get<string>("user:name");

    // Get multiple values
    const entries = await storage.get<string>(["user:name", "user:email", "user:role"]);
    // Returns: Map<string, string>

    // Delete
    await storage.delete("key");
    await storage.delete(["key1", "key2"]);

    // List all keys (with optional prefix, limit, and cursor)
    const all = await storage.list();
    // Returns: Map<string, unknown>

    const userKeys = await storage.list({ prefix: "user:" });
    const paginated = await storage.list({ prefix: "msg:", limit: 100, reverse: true });

    // Delete all data
    await storage.deleteAll();

    return new Response("OK");
  }
}

Transactions

async function transferCredits(
  storage: DurableObjectStorage,
  fromUser: string,
  toUser: string,
  amount: number
): Promise<boolean> {
  return await storage.transaction(async (txn) => {
    const fromBalance = (await txn.get<number>(`balance:${fromUser}`)) || 0;
    const toBalance = (await txn.get<number>(`balance:${toUser}`)) || 0;

    if (fromBalance < amount) {
      // Transaction will be rolled back if we throw
      return false;
    }

    await txn.put(`balance:${fromUser}`, fromBalance - amount);
    await txn.put(`balance:${toUser}`, toBalance + amount);
    return true;
  });
}

In-memory state with blockConcurrencyWhile

Use blockConcurrencyWhile in the constructor to load state into memory before handling any requests:

export class GameRoom implements DurableObject {
  private players: Map<string, Player> = new Map();
  private gameState: GameState | null = null;

  constructor(private state: DurableObjectState, private env: Env) {
    // Load state into memory during initialization
    state.blockConcurrencyWhile(async () => {
      this.gameState = await state.storage.get<GameState>("gameState") ?? null;
      const playerEntries = await state.storage.list<Player>({ prefix: "player:" });
      for (const [key, value] of playerEntries) {
        const id = key.replace("player:", "");
        this.players.set(id, value);
      }
    });
  }

  async fetch(request: Request): Promise<Response> {
    // this.players and this.gameState are guaranteed to be loaded
    // ...
  }
}

WebSocket Support

Durable Objects can accept WebSocket connections, making them ideal for real-time collaboration:

export class ChatRoom implements DurableObject {
  private sessions: Map<WebSocket, { name: string }> = new Map();

  constructor(private state: DurableObjectState, private env: Env) {}

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

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

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

      const name = url.searchParams.get("name") || "Anonymous";
      await this.handleWebSocket(server, name);

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

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

  private async handleWebSocket(ws: WebSocket, name: string) {
    // Accept the connection
    ws.accept();
    this.sessions.set(ws, { name });

    // Broadcast join
    this.broadcast(JSON.stringify({ type: "join", name }), ws);

    // Send message history
    const history = await this.state.storage.get<string[]>("messages") || [];
    ws.send(JSON.stringify({ type: "history", messages: history.slice(-50) }));

    ws.addEventListener("message", async (event) => {
      const data = JSON.parse(event.data as string);

      if (data.type === "message") {
        const msg = { name, text: data.text, timestamp: Date.now() };
        const serialized = JSON.stringify({ type: "message", ...msg });

        // Persist to storage
        const history = await this.state.storage.get<string[]>("messages") || [];
        history.push(serialized);
        if (history.length > 1000) history.splice(0, history.length - 1000);
        await this.state.storage.put("messages", history);

        // Broadcast to all connected clients
        this.broadcast(serialized);
      }
    });

    ws.addEventListener("close", () => {
      this.sessions.delete(ws);
      this.broadcast(JSON.stringify({ type: "leave", name }));
    });
  }

  private broadcast(message: string, exclude?: WebSocket) {
    for (const [ws] of this.sessions) {
      if (ws !== exclude) {
        try { ws.send(message); } catch { this.sessions.delete(ws); }
      }
    }
  }
}

Hibernatable WebSockets

For long-lived connections, use the Hibernation API to avoid paying for idle CPU time:

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

  async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // Use acceptWebSocket instead of ws.accept()
    this.state.acceptWebSocket(server, ["user-tag"]);

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

  // Called when a message arrives (object wakes from hibernation if needed)
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    const sockets = this.state.getWebSockets();
    for (const socket of sockets) {
      socket.send(typeof message === "string" ? message : new Uint8Array(message));
    }
  }

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

Alarm Handlers

Alarms let a Durable Object schedule itself to run at a future time — useful for delayed processing, cleanup, and periodic tasks:

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

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

    if (url.pathname === "/schedule") {
      const { task, delaySeconds } = await request.json<{ task: string; delaySeconds: number }>();

      // Store the task
      const tasks = (await this.state.storage.get<string[]>("tasks")) || [];
      tasks.push(task);
      await this.state.storage.put("tasks", tasks);

      // Schedule alarm (only one alarm at a time per object)
      const currentAlarm = await this.state.storage.getAlarm();
      if (!currentAlarm) {
        await this.state.storage.setAlarm(Date.now() + delaySeconds * 1000);
      }

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

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

  // Called when the alarm fires
  async alarm(): Promise<void> {
    const tasks = (await this.state.storage.get<string[]>("tasks")) || [];

    if (tasks.length > 0) {
      const task = tasks.shift()!;
      await processTask(task);
      await this.state.storage.put("tasks", tasks);

      // Schedule next alarm if more tasks remain
      if (tasks.length > 0) {
        await this.state.storage.setAlarm(Date.now() + 1000);
      }
    }
  }
}

Use Case: Rate Limiter

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

  async fetch(request: Request): Promise<Response> {
    const { key, limit, windowMs } = await request.json<{
      key: string; limit: number; windowMs: number;
    }>();

    const now = Date.now();
    const windowStart = now - windowMs;

    // Get existing timestamps
    let timestamps = (await this.state.storage.get<number[]>(`rl:${key}`)) || [];

    // Remove expired entries
    timestamps = timestamps.filter((t) => t > windowStart);

    if (timestamps.length >= limit) {
      const retryAfter = Math.ceil((timestamps[0] + windowMs - now) / 1000);
      return Response.json(
        { allowed: false, remaining: 0, retryAfter },
        { status: 429, headers: { "Retry-After": retryAfter.toString() } }
      );
    }

    // Record this request
    timestamps.push(now);
    await this.state.storage.put(`rl:${key}`, timestamps);

    return Response.json({
      allowed: true,
      remaining: limit - timestamps.length,
    });
  }
}

Troubleshooting

  • "Durable Object storage operation exceeded timeout" — Keep transactions short. Avoid heavy computation inside storage.transaction().
  • "Durable Object has been evicted" — Objects are evicted after ~10 seconds of inactivity. In-memory state is lost. Use blockConcurrencyWhile to reload state.
  • High latency — The object runs in one location. Clients far away see higher latency. Consider using KV for read caching on top of Durable Objects for writes.
  • Migration errors — Ensure the [[migrations]] tag in wrangler.toml is updated when adding, renaming, or removing Durable Object classes.

Install this skill directly: skilldb add cloudflare-workers-skills

Get CLI access →