Skip to content

Cloud & Infrastructure · Edge Computing

Cloudflare Durable Objects: Stateful Edge Computing That Actually Works

Durable Objects solve the coordination problem that makes edge computing hard: how do you maintain consistent state across distributed nodes? Here's how they work and when to use them.

Anurag Verma

Anurag Verma

9 min read

Cloudflare Durable Objects: Stateful Edge Computing That Actually Works

Sponsored

Share

Cloudflare Workers are stateless by design. Each request handler starts fresh, processes, and exits. That’s what makes them fast and infinitely scalable, but it also means you can’t hold mutable state in memory. The moment you need something like “how many active connections are in this chat room right now?” you’re stuck reaching for a database. Now your edge function has a latency floor set by the database round trip.

Durable Objects are Cloudflare’s answer to that problem. They’re stateful, strongly consistent, and single-threaded. Each object instance runs in exactly one location at a time, processes requests one at a time, and persists its state to Cloudflare’s storage. You get the coordination guarantees of a single-process system with the global reach of edge infrastructure.

The Core Model

A Durable Object is a JavaScript class that Cloudflare instantiates and keeps alive. The key properties:

  • Single-instance per ID: For a given ID, there’s exactly one running instance, somewhere in Cloudflare’s network.
  • Location pinning: The instance migrates to be close to where requests originate. If most requests come from Mumbai, the instance runs near Mumbai.
  • Serialized request handling: Requests to the same instance are queued. No concurrent execution, no race conditions.
  • Built-in storage: Each instance has access to a transactional key-value store. Data persists across hibernation periods.

Here’s the minimal definition:

export class Counter implements DurableObject {
  private count: number = 0;
  private state: DurableObjectState;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    // Restore from storage on startup
    this.state.blockConcurrencyWhile(async () => {
      this.count = (await this.state.storage.get('count')) ?? 0;
    });
  }

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

    if (url.pathname === '/increment') {
      this.count++;
      await this.state.storage.put('count', this.count);
      return Response.json({ count: this.count });
    }

    if (url.pathname === '/value') {
      return Response.json({ count: this.count });
    }

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

And the Worker that routes requests to the right instance:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    
    // Extract a namespace from the URL — each unique ID gets its own object
    const counterId = url.searchParams.get('id') ?? 'global';
    
    // Get or create the Durable Object stub
    const id = env.COUNTER.idFromName(counterId);
    const stub = env.COUNTER.get(id);
    
    // Forward the request to the object
    return stub.fetch(request);
  }
};

The wrangler.toml binding:

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

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

Why This Is Different From KV

Cloudflare KV is a global distributed key-value store. It’s eventually consistent: a write in one region takes a few seconds to propagate everywhere. For most reads, that’s fine. For counters, leaderboards, inventory, and anything where the current value matters before you write, it’s a serious problem.

With KV, two simultaneous increments on the same counter will both read the old value, both add 1, and both write back the same new value. You lose an increment.

Durable Objects don’t have that problem. Because all requests to a given object are serialized, the second increment request waits until the first completes. No lost updates.

Cloudflare KVDurable Objects
ConsistencyEventualStrong
Read latency~1ms (from cache)~10ms (round trip to instance)
Concurrent writesRace conditionsSerialized, safe
Best forConfig, cached dataCoordination, counters, sessions
Pricing (storage)$0.50/GB-month$0.20/GB-month
Pricing (reads)$0.50/million$0.20/million

Real-Time Collaboration: A Chat Room

The most common Durable Object use case is real-time coordination. Here’s a WebSocket chat room where the Durable Object tracks connected clients and broadcasts messages:

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

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    // Accept WebSocket connections from hibernated state
    this.state.getWebSockets().forEach((ws) => {
      const meta = ws.deserializeAttachment() as { name: string };
      this.sessions.set(ws, meta);
    });
  }

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

    const url = new URL(request.url);
    const name = url.searchParams.get('name') ?? 'anonymous';

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

    // Accept and register with Cloudflare's WebSocket hibernation API
    this.state.acceptWebSocket(server);
    server.serializeAttachment({ name });
    this.sessions.set(server, { name });

    this.broadcast({
      type: 'system',
      text: `${name} joined`,
      time: Date.now(),
    }, null);

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

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    const session = this.sessions.get(ws);
    if (!session) return;

    const text = typeof message === 'string' ? message : new TextDecoder().decode(message);

    this.broadcast({
      type: 'message',
      name: session.name,
      text,
      time: Date.now(),
    }, ws);
  }

  async webSocketClose(ws: WebSocket, code: number): Promise<void> {
    const session = this.sessions.get(ws);
    this.sessions.delete(ws);
    if (session) {
      this.broadcast({
        type: 'system',
        text: `${session.name} left`,
        time: Date.now(),
      }, null);
    }
  }

  private broadcast(data: object, exclude: WebSocket | null): void {
    const message = JSON.stringify(data);
    this.sessions.forEach((_, ws) => {
      if (ws !== exclude && ws.readyState === WebSocket.READY_STATE_OPEN) {
        ws.send(message);
      }
    });
  }
}

The getWebSockets() call in the constructor is the hibernation API at work. Cloudflare can pause the object when no requests are active and wake it up when a new message arrives, without losing the WebSocket connections. You pay for compute only when processing messages, not for idle connections.

Rate Limiting Per User

Rate limiting is another classic Durable Object use case. A global rate limiter that’s actually accurate (no race conditions) across any number of Worker instances:

export class RateLimiter implements DurableObject {
  private state: DurableObjectState;

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

  async fetch(request: Request): Promise<Response> {
    const now = Date.now();
    const windowMs = 60_000; // 1 minute window
    const limit = 100; // requests per window

    const windowStart = now - windowMs;
    
    // Get existing request timestamps for this window
    const timestamps: number[] = (await this.state.storage.get('timestamps')) ?? [];
    
    // Remove expired entries
    const active = timestamps.filter((t) => t > windowStart);
    
    if (active.length >= limit) {
      const oldestActive = Math.min(...active);
      const resetIn = Math.ceil((oldestActive + windowMs - now) / 1000);
      
      return Response.json(
        { allowed: false, resetIn },
        { status: 429, headers: { 'Retry-After': String(resetIn) } }
      );
    }

    active.push(now);
    await this.state.storage.put('timestamps', active);

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

In your Worker:

async function checkRateLimit(userId: string, env: Env): Promise<boolean> {
  const id = env.RATE_LIMITER.idFromName(userId);
  const limiter = env.RATE_LIMITER.get(id);
  
  const response = await limiter.fetch('https://rate-limiter/check');
  const { allowed } = await response.json();
  return allowed;
}

Each user gets their own Durable Object instance. The rate limiter for user A and user B never touch the same storage or contend with each other. And unlike a Redis-based rate limiter, this one doesn’t require a separate infrastructure component.

Storage Patterns

The Durable Object storage API is transactional and supports batching:

// Atomic multi-key operations
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);
  await txn.put('lastDebit', { amount, time: Date.now() });
});

// List keys with prefix
const entries = await this.state.storage.list({ prefix: 'session:' });

// Delete a range
await this.state.storage.deleteAll(); // Use carefully

Storage limits to know: each object can hold up to 128 KB per value, and the total storage per object isn’t capped in the pricing documentation (you pay per GB stored). Individual operations have a 1 MB payload limit.

The Alarm API: Scheduled Work per Object

Each Durable Object can schedule a future callback via the alarm API. This is useful for cleanup tasks, expiry logic, and delayed processing:

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

  async fetch(request: Request): Promise<Response> {
    // Create a session that expires in 24 hours
    const sessionId = crypto.randomUUID();
    await this.state.storage.put(`session:${sessionId}`, {
      created: Date.now(),
      data: await request.json(),
    });

    // Schedule cleanup
    const alarm = await this.state.storage.getAlarm();
    if (!alarm) {
      await this.state.storage.setAlarm(Date.now() + 86_400_000); // 24h
    }

    return Response.json({ sessionId });
  }

  async alarm(): Promise<void> {
    // Called by Cloudflare when the alarm fires
    const cutoff = Date.now() - 86_400_000;
    const sessions = await this.state.storage.list({ prefix: 'session:' });

    const toDelete: string[] = [];
    for (const [key, value] of sessions) {
      if ((value as { created: number }).created < cutoff) {
        toDelete.push(key);
      }
    }

    if (toDelete.length > 0) {
      await this.state.storage.delete(toDelete);
    }

    // Reschedule if there are still active sessions
    const remaining = await this.state.storage.list({ prefix: 'session:' });
    if (remaining.size > 0) {
      await this.state.storage.setAlarm(Date.now() + 3_600_000); // check again in 1h
    }
  }
}

When Not to Use Durable Objects

Durable Objects fit a specific niche. They’re not a general-purpose database replacement.

Skip Durable Objects when:

  • You need to query across many objects. Each DO only knows its own state. Cross-object queries don’t exist; you’d need a separate index layer.
  • Your state is read-heavy with infrequent writes. KV with its caching is cheaper and faster for that pattern.
  • You need full SQL queries, joins, or aggregations. Use D1 (Cloudflare’s SQLite) instead.
  • Object count will be enormous but most objects are inactive. Durable Objects are priced on active usage, but the coordination overhead adds up if you’re waking objects constantly.

Use Durable Objects when:

  • You need a single authoritative state that multiple clients or Workers might update simultaneously.
  • You’re building real-time features: collaborative editing, live presence, shared counters, game state.
  • You need per-tenant rate limiting, per-room coordination, or per-session state without an external service.
  • You want to avoid an external state layer (Redis, Postgres) just for coordination logic.

The pricing is reasonable for the use cases it fits: $0.20 per million requests, $12.50 per million GB-seconds of CPU time. Most coordination objects use very little CPU and receive bursts of requests. For a chat room with 100 active users sending occasional messages, the cost is negligible.

Where Durable Objects stand out is the operational simplicity. No Redis cluster to manage, no connection pooling, no failover configuration. The object lives wherever Cloudflare decides, migrates as needed, and wakes up when it gets traffic. The coordination complexity that typically requires careful distributed systems work is handled by the platform.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored