Skip to content

Workers Durable Objects -- Stateful Serverless

DodaTech 7 min read

In this tutorial, you'll learn about Workers Durable Objects. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Cloudflare Workers Durable Objects are single-instance, strongly consistent coordination primitives that maintain state across requests, enabling real-time collaboration, distributed locking, and Transaction processing at the edge without external databases.

Why Durable Objects Matter

Standard Workers are stateless -- each request may hit a different edge location, and in-memory state disappears when the request ends. Durable Objects solve this by providing a unique instance that lives on a single edge node and processes all requests for that object in sequence. Each Durable Object has its own private persistent storage using SQLite, ensuring every read reflects the latest write. This architecture enables use cases that are impossible with stateless Workers alone: multiplayer game state, real-time document collaboration, rate-limiting counters, and distributed queues. Unlike Cloudflare's Workers KV which offers eventual consistency, Durable Objects give you linearizability -- every operation sees a consistent view of the state.

Real-world use: A collaborative whiteboard app uses a Durable Object per room. When any user draws a line, the object updates its in-memory state, persists it to storage, and broadcasts the change to all connected WebSocket clients via HibernationWebSocket -- all within a single, strongly consistent instance.

Durable Object Architecture

flowchart TD
    R[HTTP Request] --> R1[Route to Edge]
    R1 --> W[Stateless Worker]
    W --> DO[Durable Object stub]
    DO --> I[Single Instance on One Node]
    I --> S[(SQLite Storage)]
    I --> WS[WebSocket connections]
    I --> M[In-memory state]

    subgraph DO_Instance
        I --> H[Handler methods]
        H --> S
        H --> WS
    end

    style DO fill:#f90,color:#fff
    style I fill:#3498db,color:#fff
    style S fill:#2ecc71,color:#fff

Each Durable Object has a unique ID derived from a name or generated randomly. The first request to a given ID creates the instance; subsequent requests route to the same existing instance. The object processes one request at a time, eliminating race conditions.

Basic Counter Durable Object

// counter.mjs
import { DurableObject } from 'cloudflare:workers';

export class Counter extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.count = 0;
  }

  async initialize() {
    const stored = await this.ctx.storage.get('count');
    this.count = stored || 0;
  }

  async increment(amount = 1) {
    this.count += amount;
    await this.ctx.storage.put('count', this.count);
    return this.count;
  }

  async getCount() {
    return this.count;
  }
}

// wrangler.toml
// [durable_objects]
// bindings = [{ name = "COUNTER", class_name = "Counter" }]
// [[migrations]]
// tag = "v1"
// new_classes = ["Counter"]
// worker.mjs
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const id = env.COUNTER.idFromName('global-counter');
    const stub = env.COUNTER.get(id);
    await stub.initialize();

    if (url.pathname === '/increment') {
      const count = await stub.increment();
      return new Response(`Count: ${count}`);
    }

    const count = await stub.getCount();
    return new Response(`Current count: ${count}`);
  }
};

// Expected output:
// GET /increment -> "Count: 1"
// GET /increment -> "Count: 2"
// GET / -> "Current count: 2"

The Counter class extends DurableObject and has its own in-memory state. The storage API persists data to SQLite. The Worker retrieves a stub using a named ID, ensuring all requests route to the same instance.

WebSocket Chat Room with Hibernation

import { DurableObject } from 'cloudflare:workers';

export class ChatRoom extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.sessions = new Map();
    ctx.setWebSocketAutoResponse(
      new Response(null, { status: 101 })
    );
  }

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

    this.ctx.acceptWebSocket(server);
    this.sessions.set(server, { joinedAt: Date.now() });

    server.addEventListener('message', (event) => {
      const msg = JSON.parse(event.data);
      for (const ws of this.sessions.keys()) {
        if (ws !== server) {
          ws.send(JSON.stringify({
            user: msg.user,
            text: msg.text,
            timestamp: Date.now()
          }));
        }
      }
    });

    server.addEventListener('close', () => {
      this.sessions.delete(server);
    });

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

  async alarm() {
    const count = this.sessions.size;
    for (const ws of this.sessions.keys()) {
      ws.send(JSON.stringify({ type: 'heartbeat', activeUsers: count }));
    }
    await this.ctx.storage.setAlarm(Date.now() + 30000);
  }
}

// Expected behavior:
// 1. Two clients connect via WebSocket
// 2. Client A sends {"user": "Alice", "text": "Hello!"}
// 3. Client B receives {"user": "Alice", "text": "Hello!", "timestamp": ...}
// 4. Every 30 seconds, both receive a heartbeat with active user count

Durable Objects with WebSockets enable real-time multi-user applications. The acceptWebSocket API promotes the WebSocket to use Hibernation API, which suspends the object when no messages are being processed, saving memory and cost.

Distributed Rate Limiter

import { DurableObject } from 'cloudflare:workers';

export class RateLimiter extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.windows = new Map();
  }

  async check(key, maxRequests, windowSeconds) {
    const now = Math.floor(Date.now() / 1000);
    const windowKey = `${key}:${Math.floor(now / windowSeconds)}`;
    const current = this.windows.get(windowKey) || 0;

    if (current >= maxRequests) {
      return { allowed: false, remaining: 0, resetIn: windowSeconds };
    }

    this.windows.set(windowKey, current + 1);
    setTimeout(() => this.windows.delete(windowKey), windowSeconds * 1000);
    return { allowed: true, remaining: maxRequests - current - 1, resetIn: windowSeconds };
  }
}

// Worker usage
export default {
  async fetch(request, env) {
    const ip = request.headers.get('CF-Connecting-IP');
    const id = env.LIMITER.idFromName('global-limiter');
    const stub = env.LIMITER.get(id);
    const result = await stub.check(ip, 100, 60);

    if (!result.allowed) {
      return new Response('Rate limit exceeded', { status: 429 });
    }

    return new Response('OK');
  }
};

// Expected output:
// 100th request from same IP within 60s -> "OK"
// 101st request from same IP within 60s -> "Rate limit exceeded" (429)

This pattern uses a sliding-window counter within a Durable Object to enforce rate limits across all edge locations. Because the Durable Object is a single instance, the counter is globally accurate without race conditions.

Common Errors

Error Cause Fix
Durable Object not found Class name mismatch in wrangler.toml Verify that class_name matches the exported class name in the Worker
Cannot modify storage outside constructor Storage accessed before initialization Move storage operations into the constructor or use ctx.storage only within handler methods
<a href="/apis/websocket/">WebSocket</a> closed before accept Response sent before calling acceptWebSocket Call ctx.acceptWebSocket(server) before returning the 101 response
Too many <a href="/apis/websocket/">WebSocket</a> connections Free plan connection limit exceeded Upgrade to a paid plan or limit concurrent connections per object
Migration required New Durable Object class added without Migration tag Add a [[migrations]] block in wrangler.toml with the new class name

Practice Questions

  1. How does a Durable Object guarantee strong consistency across requests?
  2. What is the purpose of the alarm handler in a Durable Object?
  3. How do you obtain a stub to a specific Durable Object instance from a Worker?

FAQ

Can a Durable Object run on multiple edge nodes simultaneously?

No. Each Durable Object ID maps to exactly one instance on one edge node at a time. This single-instance model is what enables strong consistency. If you need horizontal scaling, partition your data across multiple object IDs.

What happens if the edge node running my Durable Object crashes?

Cloudflare detects the failure and re-creates the Durable Object on another healthy edge node. The object's persistent storage is recovered from the SQLite-backed storage layer. In-memory state is lost and must be re-initialized from storage in the constructor.

How does Durable Object pricing work?

You are billed for the duration the object is active (processing requests or holding WebSocket connections) plus storage costs. Objects that are idle with no open connections do not incur active duration charges. The Hibernation API reduces costs by suspending inactive WebSocket objects.

Summary

Workers Durable Objects provide single-instance, strongly consistent stateful Serverless execution on Cloudflare's edge network. Each object has private SQLite storage, processes requests sequentially, and supports WebSocket connections for real-time applications. Use Durable Objects for collaborative apps, distributed Rate Limiting, Transaction processing, and coordination tasks where strong consistency is required. Combined with stateless Workers and KV, they form a complete Serverless architecture that powers applications for Doda Browser's sync features and Durga Antivirus Pro's real-time threat coordination.

This guide is brought to you by the developers of Cloudflare, REST APIs, and Durga Antivirus Pro at DodaTech.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro