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?
- The Actor Model
- Lifecycle and Persistence
- Durable Objects vs D1
- When to Use Durable Objects
- When NOT to Use Durable Objects
- Use Case Examples
- AWS and Open Source Equivalents
- Decision Framework
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:
- Has a globally unique ID (like
user:12345orroom:lobby) - Lives geographically close to where it's first accessed
- Processes requests one at a time, in order (no race conditions)
- Has its own private storage (SQLite or key-value)
- Can hold in-memory state between requests
- 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
| Feature | Description |
|---|---|
| Single-threaded | One request at a time, no race conditions |
| Strongly consistent | Reads always see latest writes |
| Colocated | Compute and storage on same machine = fast |
| Auto-scaled | Each object is independent, scales horizontally |
| Globally addressable | Access 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
- Each actor is independent — has its own private state
- Communication via messages — no shared memory
- Sequential processing — one message at a time per actor
- 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
| Type | Persists Forever? | Survives Eviction? | Survives Restart? |
|---|---|---|---|
| In-memory (JS variables) | No | No | No |
| Storage (SQLite/KV) | Yes | Yes | Yes |
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:
- It shuts down (evicted), AND
- 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 evictionPersistence 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
| Question | Answer |
|---|---|
| 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
| Aspect | D1 | Durable Objects |
|---|---|---|
| Mental Model | Traditional database | Actor with private state |
| Data Location | Centralized, shared | Per-object, isolated |
| Access Pattern | Any Worker queries it | Route to specific object by ID |
| Consistency | Eventual (read replicas) | Strong (single-threaded) |
| Scaling | One DB, many readers | Millions of objects |
| Max Storage | 10 GB per database | 1 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 acrossQuery 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 upfrontWhen to Use Durable Objects
Ideal Use Cases
Durable Objects excel when data naturally partitions by ID:
| Use Case | Object ID | What's Stored |
|---|---|---|
| User preferences | user:{userId} | Settings, theme, notifications |
| Shopping cart | cart:{sessionId} | Items, quantities |
| Document editing | doc:{docId} | Content, revision history |
| Chat room | room:{roomId} | Messages, participants |
| Game match | match:{matchId} | Players, scores, state |
| IoT device | device:{deviceId} | Readings, config |
| Rate limiter | ratelimit:{ip} | Request counts |
| Distributed lock | lock:{resourceId} | Owner, expiry |
Why Durable Objects Shine
- Real-time coordination — WebSockets, live updates
- Single-threaded guarantees — No race conditions
- Colocated compute + storage — Ultra-low latency
- Per-entity isolation — Natural data boundaries
- Automatic scaling — Each object is independent
When NOT to Use Durable Objects
Don't Use When You Need:
-
Cross-entity queries
// ❌ Can't do this "Find all users in Japan" "Get top 10 products by sales" "Count orders by status" -
Traditional CRUD with search
// ❌ Use D1 instead "Search files by name" "List items by category" "Filter by date range" -
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
| Technology | Type | Similarity | Notes |
|---|---|---|---|
| AWS Lambda Durable Functions | Workflow | Medium | Checkpoint/replay model, not true actors |
| Microsoft Orleans | Virtual actors | High | .NET, open source, powers Halo |
| Akka / Apache Pekko | Actor framework | High | JVM (Scala/Java), Pekko is Apache-licensed fork |
| Dapr | Sidecar runtime | Medium | Any language, virtual actors inspired by Orleans |
| Akka.NET | Actor framework | High | .NET port of Akka |
| Temporal | Workflow engine | Low | Durable execution, different model |
Key Differences from AWS
AWS doesn't have a direct equivalent. Closest options:
| AWS Option | Limitation |
|---|---|
| Lambda + DynamoDB | No single-threaded guarantees |
| Lambda Durable Functions | Workflow orchestration, not actors |
| ElastiCache | Shared state, not per-object isolation |
| Step Functions | Workflow coordination, not real-time |
What Makes Durable Objects Unique
- Compute + storage colocated — Same machine, ultra-low latency
- Strong consistency — Single-threaded per object
- Automatic geographic placement — Moves to where it's needed
- Serverless — No infrastructure to manage
- 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
| Need | D1 | Durable Objects | KV |
|---|---|---|---|
| Query across entities | ✅ | ❌ | ❌ |
| Real-time WebSockets | ❌ | ✅ | ❌ |
| Strong consistency | ❌ | ✅ | ❌ |
| Per-entity isolation | ❌ | ✅ | ❌ |
| Simple caching | ❌ | Overkill | ✅ |
| 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 sessionsPattern 3: Durable Objects Only
Good for: rate limiting, distributed locks, counters
Each object is independent, no cross-querying neededPricing Reference
| Metric | Free | Paid ($5/mo) | Overage |
|---|---|---|---|
| Requests | 100,000/day | 1 million/month | $0.15 per million |
| Duration | 13,000 GB-s/day | 400,000 GB-s/month | $12.50 per million GB-s |
| Storage (SQLite) | 5 GB | 5 GB | $0.20 per GB-month |
| Rows Read | 5 million/day | — | $0.001 per million |
| Rows Written | 100,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