Web Engineering ·

Cloudflare Durable Objects in Practice: Building Stateful Edge Applications

A practical guide to Cloudflare Durable Objects: how the actor model works at the edge, when to use them for rate limiting, collaborative editing, game state, and session management, with TypeScript code for storage, alarms, and WebSocket hibernation.

Cloudflare Durable Objects in Practice: Building Stateful Edge Applications

Most primitives in the Cloudflare Workers ecosystem are stateless by design. A Worker runs, does its work, and vanishes. KV handles eventually-consistent global reads. D1 handles relational data with a primary region. These tools cover a lot of ground. But they share one constraint: they do not give you a single point of coordination for mutable state.

That is the gap Durable Objects fill. Not “global state,” but coordinated state for a specific entity, guaranteed to run in exactly one place at a time, with consistent reads and writes.

This article covers the mental model, practical use cases, storage and alarm APIs, WebSocket hibernation, the billing model, and when you should reach for something else instead.

The Mental Model: Single-Threaded Actors at the Edge

A Durable Object is an actor. Each instance has:

  • A unique ID (either auto-generated or derived from a name you choose)
  • A single-threaded execution environment (requests to the same object are serialized)
  • Persistent storage (a key-value store scoped to that instance)
  • A geographic home (Cloudflare picks a PoP based on the first request, then pins it there)

The critical property is serialization. Unlike Workers, which can run many concurrent invocations, requests to the same Durable Object instance queue up and execute one at a time. This eliminates the entire class of race conditions that plague distributed systems that try to coordinate through a shared database.

The trade-off is latency. If a user in Tokyo hits a Durable Object that lives in Frankfurt (because the first request originated there), every operation routes through Frankfurt. The object does not move. You pay for coordination with a potential cross-region round trip.

Here is the basic anatomy of a Durable Object class:

// src/objects/counter.ts
export class Counter implements DurableObject {
  private state: DurableObjectState;

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

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

    if (url.pathname === '/increment') {
      const current = (await this.state.storage.get<number>('count')) ?? 0;
      const next = current + 1;
      await this.state.storage.put('count', next);
      return Response.json({ count: next });
    }

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

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

The Worker that routes to this object looks like this:

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

    // Derive object ID from a stable name: all requests for
    // the same counter key always route to the same instance.
    const counterId = env.COUNTER.idFromName('global');
    const stub = env.COUNTER.get(counterId);

    return stub.fetch(request);
  },
};

The wrangler config binds the class to the Worker:

# wrangler.toml
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

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

Use Case 1: Rate Limiting

Rate limiting is the canonical Durable Objects use case because it requires exactly what Durable Objects provide: a single authoritative counter for a given key (IP, user ID, API key), with consistent reads and writes.

A naive KV-based rate limiter fails under concurrent requests because KV lacks atomic compare-and-swap. Two simultaneous requests both read count=4, both increment to 5, and both write 5. You get one request counted as two writes but the limit is only consumed once.

With a Durable Object, the serialization guarantee makes this correct without any locking:

// src/objects/rate-limiter.ts
interface RateLimitConfig {
  limit: number;
  windowSeconds: number;
}

interface RateLimitState {
  count: number;
  windowStart: number;
}

export class RateLimiter implements DurableObject {
  private state: DurableObjectState;

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

  async fetch(request: Request): Promise<Response> {
    const config: RateLimitConfig = await request.json();
    const now = Date.now();

    const stored = await this.state.storage.get<RateLimitState>('state');
    const windowStart = stored?.windowStart ?? now;
    const count = stored?.count ?? 0;

    // Check if we are still in the current window.
    const windowExpired = now - windowStart > config.windowSeconds * 1000;
    const currentCount = windowExpired ? 0 : count;
    const currentWindowStart = windowExpired ? now : windowStart;

    if (currentCount >= config.limit) {
      const retryAfter = Math.ceil(
        (currentWindowStart + config.windowSeconds * 1000 - now) / 1000
      );
      return Response.json(
        { allowed: false, retryAfter },
        { status: 429 }
      );
    }

    await this.state.storage.put('state', {
      count: currentCount + 1,
      windowStart: currentWindowStart,
    });

    return Response.json({
      allowed: true,
      remaining: config.limit - currentCount - 1,
    });
  }
}

The Worker derives the object ID from the rate limit key, which pins each user or IP to one instance:

// In your Worker fetch handler:
async function checkRateLimit(
  env: Env,
  key: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; retryAfter?: number }> {
  const id = env.RATE_LIMITER.idFromName(key);
  const stub = env.RATE_LIMITER.get(id);

  const response = await stub.fetch('https://internal/check', {
    method: 'POST',
    body: JSON.stringify({ limit, windowSeconds }),
    headers: { 'Content-Type': 'application/json' },
  });

  return response.json();
}

Use Case 2: Collaborative Editing and Game State

Any problem where multiple clients need to see the same mutable state in near-real-time is a good fit. The pattern is: one Durable Object per room, document, or game session. All WebSocket connections for that room connect through the same object instance.

The object owns the state and broadcasts updates. No external pub/sub layer required.

// src/objects/document.ts
interface Client {
  id: string;
  socket: WebSocket;
}

export class Document implements DurableObject {
  private state: DurableObjectState;
  private clients: Map<string, Client> = new Map();

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    // Restore WebSocket connections after hibernation wakes the object.
    this.state.getWebSockets().forEach((ws) => {
      const meta = this.state.getWebSocketAutoResponse(ws);
      if (meta) {
        this.clients.set(ws.deserializeAttachment()?.clientId, {
          id: ws.deserializeAttachment()?.clientId,
          socket: ws,
        });
      }
    });
  }

  async fetch(request: Request): Promise<Response> {
    const upgradeHeader = request.headers.get('Upgrade');
    if (upgradeHeader !== 'websocket') {
      return new Response('Expected WebSocket upgrade', { status: 426 });
    }

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

    const clientId = crypto.randomUUID();

    // acceptWebSocket enters hibernation mode: the object can sleep
    // between messages without dropping connections.
    this.state.acceptWebSocket(server);
    server.serializeAttachment({ clientId });

    this.clients.set(clientId, { id: clientId, socket: server });

    // Send the current document state to the new client.
    const content = await this.state.storage.get<string>('content') ?? '';
    server.send(JSON.stringify({ type: 'init', content }));

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

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    const data = JSON.parse(message as string);
    const { clientId } = ws.deserializeAttachment();

    if (data.type === 'update') {
      // Persist the new state.
      await this.state.storage.put('content', data.content);

      // Broadcast to all other connected clients.
      const broadcast = JSON.stringify({
        type: 'update',
        content: data.content,
        from: clientId,
      });

      for (const [id, client] of this.clients) {
        if (id !== clientId) {
          try {
            client.socket.send(broadcast);
          } catch {
            this.clients.delete(id);
          }
        }
      }
    }
  }

  async webSocketClose(ws: WebSocket): Promise<void> {
    const { clientId } = ws.deserializeAttachment();
    this.clients.delete(clientId);
  }
}

Storage API Patterns

Durable Object storage is a key-value store with a few properties worth knowing before you design around it:

Reads are consistent with the single-instance guarantee. There is no replication lag. The data you read is the data the last write left.

Writes are durable. Storage writes survive object eviction (when the object goes idle and Cloudflare reclaims its memory). The next request that arrives will deserialize the object and restore state from storage.

Storage has a 128 KB value size limit. For large payloads, split across multiple keys or use R2 for the blob and store a reference key in storage.

Transactions are available. You can batch reads and writes atomically:

// Atomic compare-and-swap pattern:
await this.state.storage.transaction(async (txn) => {
  const balance = (await txn.get<number>('balance')) ?? 0;
  if (balance < amount) {
    throw new Error('Insufficient funds');
  }
  await txn.put('balance', balance - amount);
});

If the transaction callback throws, no writes are committed. This is the right primitive for anything that must be all-or-nothing.

The list() method scans keys by prefix. Useful for paginating records stored as item:{id}:

const items = await this.state.storage.list<Item>({
  prefix: 'item:',
  limit: 100,
  startAfter: cursor,
});

deleteAll() wipes the object’s storage. There is no tombstone or recovery. Use it only for explicit cleanup on object teardown.

Alarm Scheduling

Durable Objects have a built-in alarm mechanism. An alarm wakes the object at a scheduled time and calls the alarm() method. This is useful for:

  • Expiring sessions or tokens at a known future time
  • Triggering periodic cleanup within an object
  • Scheduling deferred work tied to a specific entity
// src/objects/session.ts
export class Session implements DurableObject {
  private state: DurableObjectState;

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

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

    if (url.pathname === '/create') {
      const body = await request.json<{ userId: string; ttlSeconds: number }>();
      await this.state.storage.put('userId', body.userId);
      await this.state.storage.put('createdAt', Date.now());

      // Schedule expiration. Only one alarm can be registered at a time;
      // calling setAlarm() again overwrites the previous one.
      await this.state.storage.setAlarm(Date.now() + body.ttlSeconds * 1000);

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

    if (url.pathname === '/get') {
      const userId = await this.state.storage.get<string>('userId');
      if (!userId) {
        return Response.json({ valid: false }, { status: 404 });
      }
      return Response.json({ valid: true, userId });
    }

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

  async alarm(): Promise<void> {
    // Called when the alarm fires. Clean up and the object will be evicted.
    await this.state.storage.deleteAll();
  }
}

One gotcha: an alarm wakes the object even if it has been idle. If your object manages expensive resources, the alarm() method needs to be fast and self-contained. Avoid making outbound requests in an alarm handler unless you can tolerate the object staying alive longer.

WebSocket Hibernation

Without hibernation, a Durable Object with active WebSocket connections stays alive in memory continuously. At scale, that means many objects consuming memory even when no messages are flowing.

WebSocket hibernation lets the object sleep between messages. Cloudflare keeps the TCP connections open on your behalf, wakes the object when a message arrives, delivers it via webSocketMessage(), and allows the object to sleep again.

To opt in, use this.state.acceptWebSocket(server) instead of the older server.accept() pattern. The object then implements lifecycle handlers:

export class ChatRoom implements DurableObject {
  private state: DurableObjectState;

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

  async fetch(request: Request): Promise<Response> {
    const [client, server] = Object.values(new WebSocketPair()) as [
      WebSocket,
      WebSocket
    ];
    this.state.acceptWebSocket(server);
    return new Response(null, { status: 101, webSocket: client });
  }

  // Called when the object wakes and a message is waiting.
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    // Process the message. The object will sleep again after this returns.
    const data = JSON.parse(message as string);
    ws.send(JSON.stringify({ echo: data, ts: Date.now() }));
  }

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

  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    ws.close(1011, 'Internal error');
  }
}

The billing implication is meaningful: hibernating objects are not charged for duration while sleeping. You pay only for CPU time when the object is actively processing. For a chat application with bursty traffic, this can reduce costs by an order of magnitude compared to a long-lived in-memory server.

Tradeoffs

DimensionDurable ObjectsKVD1
ConsistencyStrong (single-instance)EventualStrong (primary writes)
LatencyVariable (object location)Low globallyVariable (primary location)
Concurrency modelSerializedConcurrentConcurrent
Best forCoordination, real-timeConfig, cache, sessionsRelational data
Pricing unitCPU time + storageReads/writes + storageRows read + storage
Scale per instanceOne active request at a timeUnlimitedUnlimited
WebSocket supportNative (hibernation)NoNo

Billing Model

Durable Objects pricing has three components:

Requests: Each fetch() call to an object counts as a request. Alarm invocations also count.

Duration: Billed in 400,000 GB-second increments. An object using 128 MB of memory active for 1 second costs 0.125 GB * 1s = 0.125 GB-seconds. Hibernating objects do not accrue duration charges.

Storage: Charged per GB-month for data in the object’s storage. Small objects with modest state are cheap. Objects storing large payloads (documents, game maps) should budget storage explicitly.

At the free tier you get 1 million requests and 400,000 GB-seconds per month. For most applications, the cost is negligible until you have many simultaneously active objects with long-lived connections.

Production Considerations

Object placement is sticky but not guaranteed forever. Cloudflare can migrate an object to a different PoP in some circumstances (infrastructure events, region changes). Your code must not assume any in-memory state survives across requests unless it is also in storage.

Do not store sensitive data in memory between requests. The object may be evicted and recreated between two consecutive requests. Anything that must survive must be in this.state.storage.

Avoid blocking the event loop. Because requests are serialized, a slow request blocks all subsequent requests to that object. Keep handlers fast. Offload heavy computation to Workers or Queues.

Use blockConcurrencyWhile() for initialization. If your constructor needs to load state before handling requests, use this pattern to prevent a thundering herd on first activation:

constructor(state: DurableObjectState, env: Env) {
  this.state = state;
  this.state.blockConcurrencyWhile(async () => {
    this.config = await this.state.storage.get<Config>('config');
  });
}

Without blockConcurrencyWhile(), two requests arriving simultaneously during initialization may both find this.config undefined.

Object IDs from names are deterministic but not secret. idFromName('user:123') always produces the same ID. Do not use predictable names as a security boundary. If you need to prevent one user from accessing another user’s object, enforce that in your Worker before routing.

There is a 1,000-concurrent-connection limit per object for WebSocket hibernation. A single Durable Object is not the right design for a public chat room with 100,000 users. For that scale, use a hierarchy: many room objects, each handling a shard of users, with a coordinator object if cross-room state is needed.

When NOT to Use Durable Objects

Simple global configuration or caching. KV is cheaper and simpler. Durable Objects carry per-object overhead that is unnecessary if your reads are read-heavy and eventual consistency is acceptable.

Relational queries across many entities. D1 is the right tool for querying across rows with joins and filters. Durable Objects store per-entity state. Querying across 10,000 objects would require 10,000 stub calls.

High fan-out scenarios. If one action must update many objects simultaneously (sending a notification to all users), you will end up making thousands of stub calls from a Worker. A queue-backed fan-out with a dedicated consumer is a better design.

Latency-sensitive workloads where object location matters. A Durable Object lives in one location. If your users span the globe and the object landed in a distant PoP, every request pays a cross-region round trip. For pure coordination (locking, counters) the latency may be acceptable. For hot read paths that need sub-10ms globally, KV is a better fit.

General-purpose databases. Durable Objects are not a replacement for D1, Postgres, or any relational database. They are coordination primitives with embedded state, not a queryable data store.

Closing Thought

The value of Durable Objects is not that they eliminate infrastructure complexity. They move it. Instead of managing distributed locks, a consensus database, or a Redis cluster for coordination, you model the problem as actors and let the platform handle the serialization guarantee.

That shift is load-bearing for certain problem shapes: rate limiting, real-time collaboration, presence tracking, game sessions, per-entity workflow state. For those cases, a single Durable Object instance per entity is simpler, more consistent, and often cheaper than the alternatives. For everything else, the other Cloudflare primitives cover the ground and do not carry the geographic pinning constraint.

Know which problem you are solving before you reach for the actor model. When the fit is right, it simplifies the hardest part of distributed state management.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.