Cloudflare Durable Objects — Stateful Edge Computing without Servers
Posted on: 5/5/2026 10:11:35 AM
Table of contents
- Why Stateful at the Edge is Hard
- What Are Durable Objects?
- Actor Model at the Edge — Architecture
- SQLite Storage — A Private Database per Entity
- WebSocket Hibernation — Keep Connections, Save Money
- Alarms API — Scheduled Tasks per Entity
- System Design: Real-time Chat with Durable Objects
- Advanced Patterns
- Free Tier — Get Started at Zero Cost
- When NOT to Use Durable Objects
- Roadmap & Future
- Conclusion
Why Stateful at the Edge is Hard
Serverless functions (AWS Lambda, Cloudflare Workers, Vercel Edge Functions) are designed stateless — each request is an independent execution with no shared memory between invocations. When you need state (sessions, counters, game state, collaborative documents), the traditional solution is connecting to a centralized database — Redis, PostgreSQL, DynamoDB — hosted in a specific region.
The problem: a centralized database creates a latency bottleneck. A user in Singapore hits a Worker running at the Singapore edge, but that Worker must round-trip to a database in US-East to read/write state. Latency increases by 150-300ms per request, destroying the edge computing advantage.
The Core Problem
How do you achieve strong consistency + low latency + zero ops for stateful workloads at the edge? Cloudflare Durable Objects solve exactly this problem using the Actor Model.
What Are Durable Objects?
Durable Objects (DO) are globally unique, single-threaded compute instances running on Cloudflare's edge network. Each DO instance:
- Has a unique ID globally — exactly one instance exists at any given time
- Processes requests sequentially (single-threaded) — no race conditions
- Has its own private persistent storage (SQLite or Key-Value, up to 10GB)
- Runs at the location closest to the first user accessing it
- Automatically hibernates when idle, wakes up on demand
Actor Model at the Edge — Architecture
Durable Objects implement the Actor Model — each entity in your system (user session, chat room, game match, document) is an independent actor with its own state, processing messages sequentially.
graph TB
subgraph "Cloudflare Edge Network"
W1[Worker - Singapore] --> DO1[DO: Room-abc
SQLite + WebSocket]
W2[Worker - Tokyo] --> DO1
W3[Worker - Sydney] --> DO1
W4[Worker - London] --> DO2[DO: Room-xyz
SQLite + WebSocket]
W5[Worker - Frankfurt] --> DO2
end
U1[User Singapore] --> W1
U2[User Tokyo] --> W2
U3[User Sydney] --> W3
U4[User London] --> W4
U5[User Frankfurt] --> W5
style DO1 fill:#e94560,stroke:#fff,color:#fff
style DO2 fill:#e94560,stroke:#fff,color:#fff
style W1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style W2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style W3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style W4 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style W5 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Workers at every edge location route requests to the correct DO instance — strong consistency guaranteed by single-threaded execution
Why Single-Threaded is an Advantage
In traditional distributed systems, you need distributed locks, optimistic concurrency control, or consensus protocols (Raft, Paxos) to ensure consistency. With DO, the architecture itself eliminates all race conditions — no mutexes needed, no complex transaction isolation levels, no "read-your-writes" logic.
SQLite Storage — A Private Database per Entity
Since April 2025, Cloudflare recommends the SQLite storage backend for all new DO classes. Each DO instance gets its own isolated SQLite database with up to 10GB capacity.
// wrangler.toml
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]
// ChatRoom Durable Object
export class ChatRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
// Create table on first instantiation
ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
}
async addMessage(userId, content) {
// Single-threaded: no locks or transaction isolation needed
this.ctx.storage.sql.exec(
`INSERT INTO messages (user_id, content) VALUES (?, ?)`,
userId, content
);
// Broadcast to all WebSocket clients
const recent = this.ctx.storage.sql.exec(
`SELECT * FROM messages ORDER BY id DESC LIMIT 1`
).toArray();
this.broadcast(JSON.stringify(recent[0]));
}
}
Point-in-Time Recovery (PITR)
SQLite in DO supports restoring data to any point in time within the past 30 days. Particularly useful for collaborative editing applications that need document state rollback.
WebSocket Hibernation — Keep Connections, Save Money
One of the most important DO features is WebSocket Hibernation. When a DO receives no events for a period, it gets evicted from memory but WebSocket connections remain active on Cloudflare's infrastructure.
sequenceDiagram
participant C as Client
participant CF as Cloudflare Edge
participant DO as Durable Object
C->>CF: WebSocket Connect
CF->>DO: acceptWebSocket()
DO->>DO: Active state (processing messages)
Note over DO: 30s with no events
DO-->>CF: Hibernate (evicted from memory)
Note over CF: WebSocket connections still active
C->>CF: Send message
CF->>DO: Wake up DO
DO->>DO: deserializeAttachment()
DO->>C: Process + respond
WebSocket Hibernation: DO sleeps but connections stay alive — you only pay when there's actual traffic
export class ChatRoom extends DurableObject {
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Use acceptWebSocket instead of server.accept()
// to enable hibernation
this.ctx.acceptWebSocket(server);
// Store metadata in attachment (persists through hibernation)
server.serializeAttachment({
joinedAt: Date.now(),
userId: new URL(request.url).searchParams.get('userId')
});
return new Response(null, { status: 101, webSocket: client });
}
// Called when DO wakes from hibernation
async webSocketMessage(ws, message) {
const meta = ws.deserializeAttachment();
await this.addMessage(meta.userId, message);
}
async webSocketClose(ws) {
ws.close();
}
}
Alarms API — Scheduled Tasks per Entity
Alarms allow scheduling a DO to wake up at a specific future time. Each DO can have one active alarm at a time — setting a new alarm overwrites the previous one.
export class GameMatch extends DurableObject {
async startMatch(players) {
this.ctx.storage.sql.exec(
`INSERT INTO matches (players, status, started_at)
VALUES (?, 'active', datetime('now'))`,
JSON.stringify(players)
);
// Auto-end match after 10 minutes
await this.ctx.storage.setAlarm(Date.now() + 10 * 60 * 1000);
}
// Called when alarm fires
async alarm() {
this.ctx.storage.sql.exec(
`UPDATE matches SET status = 'completed' WHERE status = 'active'`
);
this.broadcast(JSON.stringify({ event: 'match_ended' }));
}
}
System Design: Real-time Chat with Durable Objects
Let's design a complete real-time chat system using DO — demonstrating how all features come together in production.
graph LR
subgraph "Client Layer"
A[Browser/Mobile App]
end
subgraph "Edge Layer - Cloudflare Workers"
B[Auth Worker]
C[Router Worker]
end
subgraph "Durable Objects Layer"
D[DO: User-123
Online status + prefs]
E[DO: Room-general
Messages + members]
F[DO: Room-engineering
Messages + members]
end
subgraph "Storage"
G[(SQLite per DO
Messages history)]
H[(Cloudflare KV
Room directory)]
end
A -->|WebSocket| B
B -->|Validate JWT| C
C -->|Route to room| E
C -->|Route to room| F
E --> G
F --> G
C -.->|Lookup| H
D --> G
style D fill:#e94560,stroke:#fff,color:#fff
style E fill:#e94560,stroke:#fff,color:#fff
style F fill:#e94560,stroke:#fff,color:#fff
style B fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style C fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
Chat system architecture: each room is a DO instance with its own SQLite, WebSocket Hibernation reduces cost for idle rooms
Comparison with Traditional Architecture
| Aspect | Durable Objects | Traditional (Redis + WS Server) |
|---|---|---|
| Consistency | Strong by design (single-threaded) | Requires distributed locks / Redis WATCH |
| Scaling | Automatic — 1 instance / entity | Manual sharding, sticky sessions |
| Latency | Edge near user (sub-50ms) | Centralized (100-400ms cross-region) |
| WebSocket | Native + Hibernation (90% cost savings idle) | Always-on, pay for idle connections |
| Storage | 10GB SQLite / entity, 30-day PITR | Redis: in-memory (expensive), PostgreSQL: shared |
| Operations | Zero — fully managed by Cloudflare | Provisioning, patching, monitoring, failover |
| Cold start | ~5ms (wake from hibernation) | N/A (always-on) but you pay for idle |
Advanced Patterns
1. Fan-out Pattern — Efficient Broadcasting
export class ChatRoom extends DurableObject {
broadcast(message, excludeWs = null) {
// getWebSockets() returns all connected clients
// including those in hibernation state
for (const ws of this.ctx.getWebSockets()) {
if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
}
}
2. Rate Limiting Pattern — Zero-storage Coordination
export class RateLimiter extends DurableObject {
// In-memory only — no persistence needed
requests = [];
async checkLimit(limit = 100, windowMs = 60000) {
const now = Date.now();
this.requests = this.requests.filter(t => now - t < windowMs);
if (this.requests.length >= limit) {
return { allowed: false, retryAfter: windowMs - (now - this.requests[0]) };
}
this.requests.push(now);
return { allowed: true, remaining: limit - this.requests.length };
}
}
3. Distributed Counter — Atomic Increment without Locks
export class PageViewCounter extends DurableObject {
async increment(pageId) {
// Single-threaded guarantee: no race conditions
const result = this.ctx.storage.sql.exec(
`INSERT INTO counters (page_id, count) VALUES (?, 1)
ON CONFLICT(page_id) DO UPDATE SET count = count + 1
RETURNING count`,
pageId
).toArray();
return result[0].count;
}
}
Free Tier — Get Started at Zero Cost
Since April 2025, Cloudflare opened Durable Objects to the Workers Free plan:
Free Plan Includes
- SQLite-backed Durable Objects (SQLite required, no KV)
- 5 GB total storage per account
- Unlimited requests (within Workers Free limits: 100K requests/day)
- WebSocket Hibernation
- Alarms API
With this free tier, you can fully build and deploy real-time applications like chat, collaborative editing, and multiplayer games without spending anything on infrastructure.
When NOT to Use Durable Objects
Limitations to Know
- Single-region per object: A DO runs at one location — if globally distributed users access the same object, distant users will experience higher latency
- Not a relational database: No JOINs between DOs — each entity is isolated. Need cross-entity queries? Use D1 or Hyperdrive
- 10GB limit per object: If a single entity needs more than 10GB storage, manual partitioning is required
- Cold start from hibernation: ~5ms, but still exists — not suitable for ultra-low-latency trading systems
Roadmap & Future
Conclusion
Cloudflare Durable Objects solve one of edge computing's biggest challenges: state management. By applying the Actor Model — one single-threaded instance per entity with private storage — DO completely eliminates distributed locking, race conditions, and consistency headaches without sacrificing latency or creating ops burden.
With 10GB SQLite per object, WebSocket Hibernation saving 90% on idle connections, Alarms for scheduled work, and a free tier with 5GB storage — this is the ideal platform for real-time collaboration, multiplayer games, chat systems, and AI agent sessions at the edge.
References:
RAG Pipeline 2026 — Building Hallucination-Free AI Architecture for Production
AI Agent Evaluation — Testing and Scoring AI Agents in Production
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.