Knowledge Base

Cloudflare Durable Objects: A Deep Dive

Last updated: December 2025

A comprehensive guide to understanding Cloudflare Durable Objects — what they are, how they work, when to use them, and how they compare to other storage options.

Table of Contents


What is a Durable Object?

A Durable Object is a stateful serverless unit that uniquely combines compute and storage in one place. Think of it as a tiny single-threaded server that:

  1. Has a globally unique ID (like user:12345 or room:lobby)
  2. Lives geographically close to where it's first accessed
  3. Processes requests one at a time, in order (no race conditions)
  4. Has its own private storage (SQLite or key-value)
  5. Can hold in-memory state between requests
  6. Sleeps when idle, wakes on demand
┌─────────────────────────────────────────┐
│         Durable Object "room:lobby"     │
│                                         │
│  ┌─────────────┐    ┌────────────────┐  │
│  │  Compute    │    │  Storage       │  │
│  │  (JS code)  │◄──►│  (SQLite/KV)   │  │
│  │             │    │                │  │
│  │  + memory   │    │  private to    │  │
│  │  state      │    │  this object   │  │
│  └─────────────┘    └────────────────┘  │
└─────────────────────────────────────────┘

         │ requests processed one-at-a-time
    ┌────┴────┐
    │ Client  │
    └─────────┘

Key Characteristics

FeatureDescription
Single-threadedOne request at a time, no race conditions
Strongly consistentReads always see latest writes
ColocatedCompute and storage on same machine = fast
Auto-scaledEach object is independent, scales horizontally
Globally addressableAccess any object by ID from anywhere

The Actor Model

Durable Objects implement the actor model — a programming paradigm from the 1970s that's ideal for distributed systems.

Traditional vs Actor Model

Traditional (Shared State):        Actor Model:
┌──────────────────┐              ┌─────────┐  ┌─────────┐
│   Shared State   │              │ Actor A │  │ Actor B │
│   (database)     │              │ state   │  │ state   │
└────────┬─────────┘              └────┬────┘  └────┬────┘
    ▲    │    ▲                        │            │
    │    │    │                        └─────►◄─────┘
┌───┴────┴────┴───┐                      messages
│ Thread Thread   │
│ (locks needed)  │
└─────────────────┘

Actor Model Principles

  1. Each actor is independent — has its own private state
  2. Communication via messages — no shared memory
  3. Sequential processing — one message at a time per actor
  4. No locks needed — single-threaded eliminates race conditions

Why This Matters

// Traditional: Race condition possible
async function incrementCounter(db, userId) {
  const count = await db.get(userId);  // Thread 1 reads: 5
  // Thread 2 also reads: 5
  await db.set(userId, count + 1);     // Thread 1 writes: 6
  // Thread 2 writes: 6 (should be 7!)
}
 
// Durable Object: No race condition
export class Counter {
  async increment() {
    const count = await this.state.storage.get("count") || 0;
    await this.state.storage.put("count", count + 1);
    // Single-threaded: always correct
  }
}

Lifecycle and Persistence

Two Types of State

TypePersists Forever?Survives Eviction?Survives Restart?
In-memory (JS variables)NoNoNo
Storage (SQLite/KV)YesYesYes

Object Lifecycle

Request arrives


┌─────────────────┐
│  constructor()  │  ← Object created, in-memory state initialized
│  runs           │
└────────┬────────┘


┌─────────────────┐
│  Active         │  ← Processing requests, storage + memory available
│  (in memory)    │
└────────┬────────┘

         │ 70-140 seconds idle

┌─────────────────┐
│  Evicted        │  ← In-memory state LOST, storage KEPT
│  (inactive)     │
└────────┬────────┘

         │ next request arrives

┌─────────────────┐
│  constructor()  │  ← Recreated, read state back from storage
│  runs again     │
└─────────────────┘

When is a Durable Object Deleted?

A Durable Object ceases to exist only when:

  1. It shuts down (evicted), AND
  2. Its storage is completely empty

To delete an object permanently:

await this.state.storage.deleteAll();
await this.state.storage.deleteAlarm(); // if alarms were set
// Object will be garbage collected after eviction

Persistence Best Practices

export class UserSession {
  constructor(state, env) {
    this.state = state;
    this.cache = null;  // ⚠️ In-memory, will be lost
  }
 
  async fetch(request) {
    // ✅ Persists forever
    await this.state.storage.put("lastSeen", Date.now());
 
    // ⚠️ Lost after ~2 min idle
    this.cache = { expensive: "computation" };
 
    // ✅ Pattern: Hydrate from storage if needed
    if (!this.cache) {
      this.cache = await this.state.storage.get("cachedData");
    }
  }
}

Key Facts

QuestionAnswer
Does storage persist forever?Yes, until explicitly deleted
Does in-memory state persist?No, lost after ~2 min idle
Can I rely on constructor running once?No, runs on every recreation
Is there automatic TTL/expiration?No, manage cleanup yourself

Durable Objects vs D1

Fundamental Difference

AspectD1Durable Objects
Mental ModelTraditional databaseActor with private state
Data LocationCentralized, sharedPer-object, isolated
Access PatternAny Worker queries itRoute to specific object by ID
ConsistencyEventual (read replicas)Strong (single-threaded)
ScalingOne DB, many readersMillions of objects
Max Storage10 GB per database1 GB per object (10 GB soon)

Visual Comparison

D1 (Traditional):               Durable Objects:
┌─────────────────────┐        ┌──────────┐ ┌──────────┐ ┌──────────┐
│  users table        │        │ user:1   │ │ user:2   │ │ user:3   │
│  ┌────┬──────────┐  │        │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │
│  │ 1  │ alice    │  │        │ │SQLite│ │ │ │SQLite│ │ │ │SQLite│ │
│  │ 2  │ bob      │  │        │ └──────┘ │ │ └──────┘ │ │ └──────┘ │
│  │ 3  │ charlie  │  │        └──────────┘ └──────────┘ └──────────┘
│  └────┴──────────┘  │              ▲           ▲           ▲
└─────────────────────┘         isolated    isolated    isolated

    query across

Query Capabilities

// ✅ D1 can do this
SELECT * FROM users WHERE country = 'Japan'
SELECT COUNT(*) FROM orders WHERE status = 'pending'
SELECT * FROM products ORDER BY price LIMIT 10
 
// ❌ Durable Objects cannot query across objects
// You must know the object ID upfront

When to Use Durable Objects

Ideal Use Cases

Durable Objects excel when data naturally partitions by ID:

Use CaseObject IDWhat's Stored
User preferencesuser:{userId}Settings, theme, notifications
Shopping cartcart:{sessionId}Items, quantities
Document editingdoc:{docId}Content, revision history
Chat roomroom:{roomId}Messages, participants
Game matchmatch:{matchId}Players, scores, state
IoT devicedevice:{deviceId}Readings, config
Rate limiterratelimit:{ip}Request counts
Distributed locklock:{resourceId}Owner, expiry

Why Durable Objects Shine

  1. Real-time coordination — WebSockets, live updates
  2. Single-threaded guarantees — No race conditions
  3. Colocated compute + storage — Ultra-low latency
  4. Per-entity isolation — Natural data boundaries
  5. Automatic scaling — Each object is independent

When NOT to Use Durable Objects

Don't Use When You Need:

  1. Cross-entity queries

    // ❌ Can't do this
    "Find all users in Japan"
    "Get top 10 products by sales"
    "Count orders by status"
  2. Traditional CRUD with search

    // ❌ Use D1 instead
    "Search files by name"
    "List items by category"
    "Filter by date range"
  3. Simple key-value without coordination

    // ❌ Use Workers KV instead (cheaper, simpler)
    "Store user session token"
    "Cache API response"

Use D1 Instead For:

  • User accounts, profiles
  • Product catalogs
  • Blog posts, content
  • Any data you need to query/filter/search

Use Case Examples

1. Real-Time Chat Room

// Object ID: room:{roomId}
export class ChatRoom {
  constructor(state, env) {
    this.state = state;
    this.sessions = [];  // Active WebSocket connections
  }
 
  async fetch(request) {
    if (request.headers.get("Upgrade") === "websocket") {
      const [client, server] = Object.values(new WebSocketPair());
      this.sessions.push(server);
      server.accept();
 
      server.addEventListener("message", async (event) => {
        // Save to SQLite
        await this.state.storage.sql.exec(
          "INSERT INTO messages (text, ts) VALUES (?, ?)",
          event.data, Date.now()
        );
 
        // Broadcast to all — no race conditions!
        for (const session of this.sessions) {
          session.send(event.data);
        }
      });
 
      return new Response(null, { status: 101, webSocket: client });
    }
  }
}

2. Rate Limiter

// Object ID: ratelimit:{identifier}
export class RateLimiter {
  async fetch(request) {
    const now = Date.now();
    const window = 60000; // 1 minute
    const limit = 100;
 
    const count = await this.state.storage.get("count") || 0;
    const windowStart = await this.state.storage.get("window") || now;
 
    // Reset window if expired
    if (now - windowStart > window) {
      await this.state.storage.put("count", 1);
      await this.state.storage.put("window", now);
      return Response.json({ allowed: true, remaining: limit - 1 });
    }
 
    // Check limit
    if (count >= limit) {
      return Response.json({ allowed: false, remaining: 0 }, { status: 429 });
    }
 
    // Increment — single-threaded, always accurate
    await this.state.storage.put("count", count + 1);
    return Response.json({ allowed: true, remaining: limit - count - 1 });
  }
}

3. Distributed Lock

// Object ID: lock:{resourceId}
export class DistributedLock {
  async fetch(request) {
    const { action, owner, ttl } = await request.json();
 
    if (action === "acquire") {
      const currentOwner = await this.state.storage.get("owner");
      const expiry = await this.state.storage.get("expiry");
 
      // Check if lock is held
      if (currentOwner && Date.now() < expiry) {
        return Response.json({ acquired: false, owner: currentOwner });
      }
 
      // Acquire lock
      await this.state.storage.put("owner", owner);
      await this.state.storage.put("expiry", Date.now() + ttl);
      return Response.json({ acquired: true });
    }
 
    if (action === "release") {
      const currentOwner = await this.state.storage.get("owner");
      if (currentOwner === owner) {
        await this.state.storage.delete("owner");
        await this.state.storage.delete("expiry");
        return Response.json({ released: true });
      }
      return Response.json({ released: false, error: "not owner" });
    }
  }
}

4. Order State Machine

// Object ID: order:{orderId}
export class Order {
  async fetch(request) {
    const { event } = await request.json();
    const state = await this.state.storage.get("status") || "created";
 
    // State machine transitions
    const transitions = {
      created: { pay: "paid", cancel: "cancelled" },
      paid: { ship: "shipped", refund: "refunded" },
      shipped: { deliver: "delivered" },
    };
 
    const nextState = transitions[state]?.[event];
 
    if (!nextState) {
      return Response.json({ error: `Invalid transition: ${state} -> ${event}` }, { status: 400 });
    }
 
    // Update state and log history
    await this.state.storage.put("status", nextState);
    await this.state.storage.sql.exec(
      "INSERT INTO history (from_state, to_state, event, ts) VALUES (?, ?, ?, ?)",
      state, nextState, event, Date.now()
    );
 
    return Response.json({ previousState: state, currentState: nextState });
  }
}

5. Collaborative Document (with R2)

// Object ID: doc:{docId}
// Combines: D1 (metadata), R2 (content), Durable Object (real-time)
export class Document {
  constructor(state, env) {
    this.state = state;
    this.env = env;
    this.editors = [];
    this.pendingChanges = [];
  }
 
  async fetch(request) {
    if (request.headers.get("Upgrade") === "websocket") {
      return this.handleWebSocket(request);
    }
 
    // HTTP: Get current document
    const content = await this.env.R2.get(`docs/${this.docId}`);
    return new Response(content.body);
  }
 
  async handleEdit(change) {
    this.pendingChanges.push(change);
 
    // Broadcast immediately to other editors
    this.editors.forEach(e => e.send(JSON.stringify(change)));
 
    // Batch save every 5 seconds
    if (!this.saveScheduled) {
      await this.state.storage.setAlarm(Date.now() + 5000);
      this.saveScheduled = true;
    }
  }
 
  async alarm() {
    // Merge changes and save to R2
    const merged = this.mergeChanges(this.pendingChanges);
    await this.env.R2.put(`docs/${this.docId}`, merged);
 
    // Update metadata in D1
    await this.env.DB.prepare(
      "UPDATE documents SET updated_at = ? WHERE id = ?"
    ).bind(Date.now(), this.docId).run();
 
    this.pendingChanges = [];
    this.saveScheduled = false;
  }
}

AWS and Open Source Equivalents

Comparison Table

TechnologyTypeSimilarityNotes
AWS Lambda Durable FunctionsWorkflowMediumCheckpoint/replay model, not true actors
Microsoft OrleansVirtual actorsHigh.NET, open source, powers Halo
Akka / Apache PekkoActor frameworkHighJVM (Scala/Java), Pekko is Apache-licensed fork
DaprSidecar runtimeMediumAny language, virtual actors inspired by Orleans
Akka.NETActor frameworkHigh.NET port of Akka
TemporalWorkflow engineLowDurable execution, different model

Key Differences from AWS

AWS doesn't have a direct equivalent. Closest options:

AWS OptionLimitation
Lambda + DynamoDBNo single-threaded guarantees
Lambda Durable FunctionsWorkflow orchestration, not actors
ElastiCacheShared state, not per-object isolation
Step FunctionsWorkflow coordination, not real-time

What Makes Durable Objects Unique

  1. Compute + storage colocated — Same machine, ultra-low latency
  2. Strong consistency — Single-threaded per object
  3. Automatic geographic placement — Moves to where it's needed
  4. Serverless — No infrastructure to manage
  5. WebSocket hibernation — Only pay when messages arrive

Decision Framework

Quick Decision Tree

flowchart TB
    Q1{"Do you need to query<br/>across multiple entities?"}
    Q1 -->|Yes| D1_1["Use D1"]
    Q1 -->|No| Q2{"Do you need real-time<br/>coordination or WebSockets?"}
    Q2 -->|Yes| DO_1["Durable Objects ✓"]
    Q2 -->|No| Q3{"Is data naturally isolated<br/>per user/session/resource?"}
    Q3 -->|Yes| DO_2["Durable Objects ✓<br/>(simpler, colocated)"]
    Q3 -->|No| D1_2["Use D1"]

Comparison Summary

NeedD1Durable ObjectsKV
Query across entities
Real-time WebSockets
Strong consistency
Per-entity isolation
Simple cachingOverkill
Coordination/locking

Common Architecture Patterns

Pattern 1: D1 + Durable Objects

D1 = queryable data (users, products, history)
Durable Objects = real-time features (chat, collaboration)

Pattern 2: R2 + D1 + Durable Objects

R2 = blob storage (files, images)
D1 = metadata, search index
Durable Objects = real-time editing sessions

Pattern 3: Durable Objects Only

Good for: rate limiting, distributed locks, counters
Each object is independent, no cross-querying needed

Pricing Reference

MetricFreePaid ($5/mo)Overage
Requests100,000/day1 million/month$0.15 per million
Duration13,000 GB-s/day400,000 GB-s/month$12.50 per million GB-s
Storage (SQLite)5 GB5 GB$0.20 per GB-month
Rows Read5 million/day$0.001 per million
Rows Written100,000/day$1.00 per million

Notes:

  • Free tier: SQLite storage backend only
  • Paid tier: SQLite or key-value storage
  • WebSocket hibernation: Only charged when processing messages

References