Cloudflare Workers + Durable Objects — Building Stateful Serverless Apps on the Edge

Posted on: 4/26/2026 11:10:00 AM

Cloudflare Workers Durable Objects Edge Computing Serverless System Architecture

1. The State Problem in Serverless

Serverless has revolutionized application deployment: no server management, automatic scaling, pay-per-request pricing. But there's an inherent limitation — serverless is fundamentally stateless. Each request is handled by a completely independent instance with no knowledge of previous requests.

This makes many real-world problems extremely complex: collaborative editing (Google Docs), multiplayer games, chat rooms, rate limiting, or any application requiring shared state across multiple clients. The traditional solution is adding an external database or cache layer — but that contradicts the "zero infrastructure" philosophy of serverless.

Cloudflare Workers + Durable Objects solves this elegantly: combining compute and storage within a single unit, running across 300+ global data centers, with near-zero latency between processing logic and data.

300+Global edge locations
<1msAverage cold start
10GBSQLite storage per Object
FreeTier available for DO

2. Cloudflare Workers — The Edge Computing Platform

Cloudflare Workers is a serverless platform running JavaScript/TypeScript on Cloudflare's edge network. Instead of code executing in a single region (us-east-1, ap-southeast-1...), Workers execute at the data center nearest to the user — minimizing latency.

graph LR
    A[User Vietnam] -->|Request| B[Edge Hanoi]
    C[User US] -->|Request| D[Edge New York]
    E[User EU] -->|Request| F[Edge Frankfurt]
    B --> G[Worker Instance]
    D --> H[Worker Instance]
    F --> I[Worker Instance]
    G --> J[(KV / D1 / DO)]
    H --> J
    I --> J
Workers execute at the edge location nearest to each user

Workers use V8 isolates (the same engine powering Chrome) instead of containers or VMs. Each request runs in a separate isolate — starting in microseconds instead of the milliseconds typical of Lambda/Cloud Functions.

Basic Worker Example

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === "/api/hello") {
      return Response.json({
        message: "Hello from the Edge!",
        location: request.cf?.city,
        country: request.cf?.country
      });
    }

    return fetch(request);
  }
};

V8 Isolates Advantage

V8 Isolates enable Workers to achieve ~0ms cold start (vs. 100-500ms for AWS Lambda), use 10-100x less memory than containers, and run thousands of isolates within a single process. This is why Workers pricing is significantly cheaper than traditional FaaS platforms.

3. Durable Objects — The Actor Model on the Edge

Durable Objects (DO) is the most important Workers extension, fundamentally solving the state problem. Each Durable Object is a globally single instance with a unique identifier and an embedded SQLite database.

If you're familiar with the Actor Model (Akka, Erlang, Microsoft Orleans), Durable Objects is an edge implementation of that pattern: each actor processes messages sequentially, has its own state, and its lifecycle is managed automatically.

graph TB
    subgraph "Durable Object: chat-room-42"
        DO[DO Instance] --> DB[(SQLite DB)]
        DO --> MEM[In-Memory State]
        DO --> WS[WebSocket Sessions]
    end
    
    U1[Client 1] -->|WebSocket| DO
    U2[Client 2] -->|WebSocket| DO
    U3[Client 3] -->|WebSocket| DO
    
    W1[Worker Edge HN] -->|stub.fetch| DO
    W2[Worker Edge SG] -->|stub.fetch| DO
Durable Objects ensure single-instance consistency — all requests route through one instance

Three Core Properties

PropertyDescriptionPractical Implication
Global UniquenessEach DO has a system-wide unique IDSend requests to the exact object from anywhere in the world
Single-threadedAll requests to a DO are processed sequentiallyNo locks, mutexes, or race conditions
Co-located StorageSQLite database resides with the computeZero-latency between code and data, strong consistency

Example: Chat Room with Durable Objects

// Worker entry point — route to Durable Object
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const roomId = url.searchParams.get("room") || "default";

    const id = env.CHAT_ROOM.idFromName(roomId);
    const stub = env.CHAT_ROOM.get(id);

    return stub.fetch(request);
  }
};

// Durable Object class
export class ChatRoom {
  constructor(state, env) {
    this.state = state;
    this.sessions = [];
  }

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

    const history = this.state.storage.sql.exec(
      "SELECT sender, message, timestamp FROM messages ORDER BY timestamp DESC LIMIT 50"
    ).toArray().reverse();

    server.send(JSON.stringify({ type: "history", data: history }));

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

  async webSocketMessage(ws, msg) {
    const data = JSON.parse(msg);

    this.state.storage.sql.exec(
      "INSERT INTO messages (sender, message, timestamp) VALUES (?, ?, ?)",
      data.sender, data.message, Date.now()
    );

    for (const session of this.state.getWebSockets()) {
      session.send(JSON.stringify({
        type: "message",
        sender: data.sender,
        message: data.message
      }));
    }
  }

  async webSocketClose(ws) {
    ws.close();
  }
}

WebSocket Hibernation

Durable Objects support WebSocket Hibernation — when no messages are being sent/received, the DO can "hibernate" to save resources while maintaining WebSocket connections. When a new message arrives, the DO automatically wakes up. This enables maintaining thousands of concurrent WebSocket connections at near-zero cost when idle.

4. SQLite Storage — Zero-Latency Embedded Database

Since 2025, Cloudflare transitioned Durable Objects Storage to use SQLite as the primary backend instead of the previous key-value store. This is a major advancement: you get a full relational database inside each Durable Object without connecting to any external database service.

FeatureKV Storage (Legacy)SQLite Storage (Current)
Query languageget/put/delete/listFull SQL (SELECT, JOIN, INDEX...)
Max capacity~50MB10GB per object
TransactionsNoACID transactions
IndexingKey prefix onlyCustom B-tree indexes
ConsistencyStrongSerializable isolation
BackupManual exportPoint-in-time recovery
// Initialize schema in constructor
async initialize() {
  this.state.storage.sql.exec(`
    CREATE TABLE IF NOT EXISTS sessions (
      id TEXT PRIMARY KEY,
      user_id TEXT NOT NULL,
      data TEXT,
      expires_at INTEGER,
      created_at INTEGER DEFAULT (unixepoch())
    );
    CREATE INDEX IF NOT EXISTS idx_sessions_user 
      ON sessions(user_id);
    CREATE INDEX IF NOT EXISTS idx_sessions_expires 
      ON sessions(expires_at);
  `);
}

// Query with prepared statements
getActiveSessions(userId) {
  return this.state.storage.sql.exec(
    `SELECT * FROM sessions 
     WHERE user_id = ? AND expires_at > ?`,
    userId, Date.now()
  ).toArray();
}

5. Architecture Guide: When to Use Workers, KV, D1, or DO?

Cloudflare provides multiple storage options, each suited to different use cases. Choosing the right solution is the most important architectural decision.

graph TB
    REQ[Incoming Request] --> W[Worker]
    W --> |"Static config, cache"| KV[(KV Store)]
    W --> |"Relational queries, analytics"| D1[(D1 Database)]
    W --> |"Stateful logic, realtime"| DO[Durable Objects]
    W --> |"Large files, media"| R2[(R2 Storage)]
    
    DO --> |"Per-object data"| SQL[(Embedded SQLite)]
    
    KV -.-> |"Eventually consistent"| KV
    D1 -.-> |"Strongly consistent"| D1
    DO -.-> |"Single-instance consistent"| DO
Choose the right storage based on consistency requirements and access patterns
SolutionConsistencyPrimary Use CaseFree Tier
KVEventually consistentConfig, cache, feature flags100K reads/day
D1Strong (single-region)CRUD apps, relational data5M row reads/day
Durable ObjectsStrong (single-instance)Realtime, coordination, sessionsAvailable (limited)
R2StrongObject storage, file uploads10GB storage

When NOT to Use Durable Objects

DO isn't a silver bullet. Don't use DO for: read-heavy workloads without coordination needs (use KV), complex relational queries across multiple entities (use D1), or static content serving (use R2 + Cache). DO excels when you need coordination among multiple clients on the same entity.

6. Durable Objects Facets — New in 2026

Facets is the latest Durable Objects feature, enabling dynamically loaded code to manage persistent state. Instead of each Durable Object having a single class handling all logic, Facets lets you split logic into independent modules — each facet managing its own state partition.

This is particularly important for AI Agents: an agent can have separate facets for memory, tool execution, and conversation history — each developed and deployed independently.

// Facet for AI Agent memory
export class AgentMemoryFacet {
  constructor(state) {
    this.state = state;
    state.storage.sql.exec(`
      CREATE TABLE IF NOT EXISTS memories (
        id TEXT PRIMARY KEY,
        content TEXT,
        embedding BLOB,
        created_at INTEGER
      )
    `);
  }

  async store(memory) {
    this.state.storage.sql.exec(
      "INSERT INTO memories (id, content, created_at) VALUES (?, ?, ?)",
      crypto.randomUUID(), memory.content, Date.now()
    );
  }

  async recall(query, limit = 10) {
    return this.state.storage.sql.exec(
      "SELECT * FROM memories ORDER BY created_at DESC LIMIT ?",
      limit
    ).toArray();
  }
}

7. Common Use Cases

7.1 Distributed Rate Limiter

export class RateLimiter {
  constructor(state) {
    this.state = state;
    state.storage.sql.exec(`
      CREATE TABLE IF NOT EXISTS requests (
        ip TEXT, timestamp INTEGER
      );
      CREATE INDEX IF NOT EXISTS idx_ip_ts ON requests(ip, timestamp);
    `);
  }

  async fetch(request) {
    const ip = request.headers.get("CF-Connecting-IP");
    const windowMs = 60000;
    const maxRequests = 100;
    const now = Date.now();

    this.state.storage.sql.exec(
      "DELETE FROM requests WHERE timestamp < ?", now - windowMs
    );

    const count = this.state.storage.sql.exec(
      "SELECT COUNT(*) as cnt FROM requests WHERE ip = ? AND timestamp > ?",
      ip, now - windowMs
    ).one().cnt;

    if (count >= maxRequests) {
      return new Response("Rate limit exceeded", { status: 429 });
    }

    this.state.storage.sql.exec(
      "INSERT INTO requests (ip, timestamp) VALUES (?, ?)", ip, now
    );

    return new Response("OK", {
      headers: { "X-RateLimit-Remaining": String(maxRequests - count - 1) }
    });
  }
}

7.2 Distributed Counter (E-commerce Flash Sale)

export class InventoryCounter {
  constructor(state) {
    this.state = state;
  }

  async fetch(request) {
    const url = new URL(request.url);

    if (url.pathname === "/reserve") {
      const current = (await this.state.storage.get("stock")) || 0;
      if (current <= 0) {
        return Response.json({ success: false, reason: "out_of_stock" });
      }
      await this.state.storage.put("stock", current - 1);
      return Response.json({ success: true, remaining: current - 1 });
    }

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

8. Deployment and Configuration

Configure Workers + Durable Objects via wrangler.toml:

# wrangler.toml
name = "my-app"
main = "src/index.ts"
compatibility_date = "2026-04-01"

[durable_objects]
bindings = [
  { name = "CHAT_ROOM", class_name = "ChatRoom" },
  { name = "RATE_LIMITER", class_name = "RateLimiter" }
]

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom", "RateLimiter"]

[[kv_namespaces]]
binding = "CACHE"
id = "abc123"

[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xyz789"

Deploy with Wrangler CLI:

# Install
npm install -g wrangler

# Login
wrangler login

# Local dev
wrangler dev

# Deploy to production
wrangler deploy

CI/CD with GitHub Actions

Cloudflare provides cloudflare/wrangler-action@v3 for automated deployment. Simply add CLOUDFLARE_API_TOKEN to GitHub Secrets and configure the workflow — each push to main automatically deploys to production.

9. Pricing and Free Tier

ComponentFree TierPaid (Workers Paid $5/month)
Workers Requests100K requests/day10M requests/month (then $0.30/M)
Workers CPU Time10ms CPU/request30s CPU/request
DO RequestsAvailable (limited)1M requests/month (then $0.15/M)
DO StorageAvailable (limited)1GB included (then $0.20/GB)
DO DurationAvailable (limited)400K GB-s/month
KV100K reads/day10M reads/month
D15M row reads/day25B row reads/month
R210GB storage10GB free, $0.015/GB after

Cost Comparison with AWS

A realtime chat application with 10K concurrent users on AWS requires: API Gateway WebSocket ($1/M messages) + Lambda ($0.20/M requests) + DynamoDB ($1.25/M writes) + ElastiCache. On Cloudflare: Workers + Durable Objects at 3-5x lower cost, no infrastructure management, and lower latency thanks to edge computing.

10. Comparison with Alternatives

CriteriaCF Workers + DOAWS Lambda + DynamoDBAzure Functions + Cosmos DB
Cold start~0ms100-500ms200-1000ms
Edge locations300+30+ regions60+ regions
Stateful computeNative (DO)External (Step Functions)External (Durable Functions)
WebSocketNative + HibernationAPI Gateway WebSocketSignalR Service
Embedded DBSQLite/objectNoNo
Free tierVery generousGoodGood
Vendor lock-inMediumHighHigh

11. Best Practices

Design Durable Objects Properly

1 DO = 1 logical entity. Each chat room is 1 DO, each user session is 1 DO, each game match is 1 DO. Don't create a "god" DO handling everything — that creates a bottleneck since DOs are single-threaded. Think of each DO as a microservice with its own state.

Architecture Checklist:

  • Partition by entity: each Durable Object represents one business entity (user, room, order)
  • Use Alarms for background work: instead of polling, use state.storage.setAlarm() to schedule future tasks
  • WebSocket Hibernation: always use state.acceptWebSocket() instead of manual WebSocket management — significantly reduces billable duration
  • Batch writes: group multiple storage operations into a single transaction to reduce I/O
  • Graceful migration: use migration tags in wrangler.toml when changing storage schemas
  • Error boundary: always wrap logic in try-catch as uncaught exceptions will reset DO in-memory state

References