Cloudflare Durable Objects: Stateful Edge Computing
Cloudflare Durable Objects: Stateful Edge Computing
Introduction
Edge computing traditionally meant pushing read-heavy workloads closer to users—CDNs caching static assets, serverless functions handling API requests. But what about state? Cloudflare Durable Objects change the game: strongly consistent, stateful computing at the edge.
Building Underscore.is, our AI coding agent platform, required real-time WebSocket communication with state persistence across edge locations. Traditional approaches—Redis clusters, sticky sessions, centralized databases—all added complexity and latency. Durable Objects solved this: each user workspace became an independent object with its own state, automatically migrated to the nearest edge location.
The key insight: instead of distributing state across a database, make the compute follow the state. One Durable Object instance has authority over its data, guaranteed. No distributed consensus for every write, no eventual consistency surprises.
Read the Durable Objects announcement for Cloudflare’s vision.
What Are Durable Objects?
Durable Objects are a serverless primitive that provides:
- Single-threaded consistency - Each object has one authoritative instance
- Global distribution - Automatically migrates closer to users
- WebSocket support - Maintain persistent connections
- Transactional storage - Integrated key-value storage with ACID guarantees
- Automatic hibernation - Scales to zero when idle
Think of each Durable Object as a tiny, persistent actor that can:
- Maintain in-memory state
- Handle WebSocket connections
- Coordinate complex workflows
- Store data transactionally
Architecture Overview
User (Browser) → WebSocket → Cloudflare Worker → Durable Object
↓
Transactional
Storage
Key insight: One Durable Object instance per logical entity (e.g., one per chat room, one per document, one per user session).
Your First Durable Object
Let’s build a real-time counter that multiple users can increment:
// counter.ts - Durable Object class
export class Counter {
state: DurableObjectState;
value: number = 0;
constructor(state: DurableObjectState) {
this.state = state;
// Load persisted value on initialization
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<number>('value');
this.value = stored || 0;
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
switch (url.pathname) {
case '/increment':
this.value++;
await this.state.storage.put('value', this.value);
return new Response(JSON.stringify({ value: this.value }));
case '/get':
return new Response(JSON.stringify({ value: this.value }));
case '/websocket':
return this.handleWebSocket(request);
default:
return new Response('Not found', { status: 404 });
}
}
async handleWebSocket(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Accept WebSocket connection
this.state.acceptWebSocket(server);
// Send current value immediately
server.send(JSON.stringify({ type: 'current', value: this.value }));
return new Response(null, {
status: 101,
webSocket: client
});
}
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
const data = JSON.parse(message);
if (data.type === 'increment') {
this.value++;
await this.state.storage.put('value', this.value);
// Broadcast to all connected clients
this.state.getWebSockets().forEach(socket => {
socket.send(JSON.stringify({
type: 'update',
value: this.value
}));
});
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
ws.close(code, reason);
}
}
Worker Entry Point
// index.ts - Worker that routes to Durable Objects
export interface Env {
COUNTER: DurableObjectNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Extract counter ID from URL (e.g., /counter/room-123)
const counterId = url.pathname.split('/')[2];
if (!counterId) {
return new Response('Counter ID required', { status: 400 });
}
// Get Durable Object stub
const id = env.COUNTER.idFromName(counterId);
const stub = env.COUNTER.get(id);
// Forward request to Durable Object
return stub.fetch(request);
}
};
// Export Durable Object class
export { Counter };
Wrangler Configuration
# wrangler.toml
name = "durable-counter"
main = "src/index.ts"
compatibility_date = "2025-01-28"
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
[[migrations]]
tag = "v1"
new_classes = ["Counter"]
Real-World Pattern: Chat Room
Let’s build a production-ready chat room with Durable Objects:
interface Message {
id: string;
userId: string;
username: string;
content: string;
timestamp: number;
}
interface User {
id: string;
username: string;
connection: WebSocket;
joinedAt: number;
}
export class ChatRoom {
state: DurableObjectState;
users: Map<string, User> = new Map();
messages: Message[] = [];
constructor(state: DurableObjectState) {
this.state = state;
// Load message history from storage
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<Message[]>('messages');
this.messages = stored || [];
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/websocket') {
return this.handleWebSocket(request);
}
if (url.pathname === '/messages') {
return new Response(JSON.stringify({
messages: this.messages.slice(-100) // Last 100 messages
}));
}
return new Response('Not found', { status: 404 });
}
async handleWebSocket(request: Request): Promise<Response> {
const url = new URL(request.url);
const userId = url.searchParams.get('userId');
const username = url.searchParams.get('username');
if (!userId || !username) {
return new Response('Missing userId or username', { status: 400 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Store user info
this.users.set(userId, {
id: userId,
username,
connection: server,
joinedAt: Date.now()
});
// Accept WebSocket
this.state.acceptWebSocket(server);
// Send message history
server.send(JSON.stringify({
type: 'history',
messages: this.messages.slice(-50) // Last 50 messages
}));
// Broadcast user joined
this.broadcast({
type: 'user_joined',
userId,
username,
userCount: this.users.size
}, userId);
return new Response(null, {
status: 101,
webSocket: client
});
}
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
const data = JSON.parse(message);
// Find user by WebSocket
const user = Array.from(this.users.values())
.find(u => u.connection === ws);
if (!user) return;
if (data.type === 'message') {
const msg: Message = {
id: crypto.randomUUID(),
userId: user.id,
username: user.username,
content: data.content,
timestamp: Date.now()
};
// Store message
this.messages.push(msg);
// Keep only last 1000 messages in memory
if (this.messages.length > 1000) {
this.messages = this.messages.slice(-1000);
}
// Persist to storage (async, don't block)
this.state.storage.put('messages', this.messages);
// Broadcast to all users
this.broadcast({
type: 'message',
message: msg
});
}
if (data.type === 'typing') {
// Broadcast typing indicator (don't persist)
this.broadcast({
type: 'typing',
userId: user.id,
username: user.username
}, user.id);
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
// Find and remove user
for (const [userId, user] of this.users.entries()) {
if (user.connection === ws) {
this.users.delete(userId);
// Broadcast user left
this.broadcast({
type: 'user_left',
userId,
username: user.username,
userCount: this.users.size
});
break;
}
}
ws.close(code, reason);
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error('WebSocket error:', error);
}
// Helper: Broadcast to all or all except one
private broadcast(data: any, excludeUserId?: string): void {
const message = JSON.stringify(data);
for (const [userId, user] of this.users.entries()) {
if (excludeUserId && userId === excludeUserId) continue;
try {
user.connection.send(message);
} catch (error) {
console.error(`Failed to send to user ${userId}:`, error);
}
}
}
}
Advanced Patterns
1. Automatic Hibernation
Durable Objects automatically hibernate when idle, saving costs:
export class HibernatingObject {
state: DurableObjectState;
lastActivity: number = Date.now();
constructor(state: DurableObjectState) {
this.state = state;
// Set alarm to clean up after inactivity
this.state.blockConcurrencyWhile(async () => {
const alarm = await this.state.storage.getAlarm();
if (!alarm) {
// Set alarm for 1 hour from now
await this.state.storage.setAlarm(Date.now() + 3600000);
}
});
}
async alarm(): Promise<void> {
const inactiveTime = Date.now() - this.lastActivity;
if (inactiveTime > 3600000) { // 1 hour
// Clean up resources, close connections, etc.
console.log('Object hibernating due to inactivity');
// Don't set another alarm - let object hibernate
} else {
// Still active, set another alarm
await this.state.storage.setAlarm(Date.now() + 3600000);
}
}
async fetch(request: Request): Promise<Response> {
this.lastActivity = Date.now();
// Handle request...
return new Response('OK');
}
}
2. Transactional Workflows
Use transactions for atomic operations:
export class BankAccount {
state: DurableObjectState;
async transfer(fromAccount: string, toAccount: string, amount: number): Promise<void> {
// Use transaction to ensure atomicity
await this.state.storage.transaction(async (txn) => {
const fromBalance = await txn.get<number>(`balance:${fromAccount}`) || 0;
const toBalance = await txn.get<number>(`balance:${toAccount}`) || 0;
if (fromBalance < amount) {
throw new Error('Insufficient funds');
}
// Both operations succeed or both fail
await txn.put(`balance:${fromAccount}`, fromBalance - amount);
await txn.put(`balance:${toAccount}`, toBalance + amount);
// Log transaction
await txn.put(`transaction:${Date.now()}`, {
from: fromAccount,
to: toAccount,
amount,
timestamp: Date.now()
});
});
}
}
3. Coordination Across Multiple Objects
Sometimes you need coordination across Durable Objects:
// Coordinator pattern
export class GameRoom {
state: DurableObjectState;
players: string[] = [];
async addPlayer(playerId: string, playerObjectNamespace: DurableObjectNamespace): Promise<void> {
this.players.push(playerId);
// Notify player's Durable Object
const playerStub = playerObjectNamespace.get(
playerObjectNamespace.idFromName(playerId)
);
await playerStub.fetch('http://internal/joined-game', {
method: 'POST',
body: JSON.stringify({ gameId: 'current-game' })
});
}
}
Production Lessons from Underscore.is
Building a real-time AI coding platform taught us valuable lessons:
1. Design for Single-Tenancy
Each Durable Object should represent one logical entity:
// Good: One workspace per Durable Object
const workspaceId = env.WORKSPACE.idFromName(`workspace:${userId}:${projectId}`);
// Bad: Trying to handle multiple workspaces in one object
const globalWorkspace = env.WORKSPACE.idFromName('all-workspaces'); // ❌
2. Use Alarms for Background Tasks
export class AIAgentSession {
async alarm(): Promise<void> {
// Check if agent task is still running
const task = await this.state.storage.get('current_task');
if (task && Date.now() - task.startTime > 300000) { // 5 minutes
// Task timeout - clean up and notify
await this.cancelTask(task.id);
this.broadcast({ type: 'task_timeout', taskId: task.id });
}
// Set next alarm
await this.state.storage.setAlarm(Date.now() + 60000); // Check every minute
}
}
3. Handle WebSocket Lifecycle Properly
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
// Always clean up state
const session = this.sessions.get(ws);
if (session) {
await this.cleanupSession(session.id);
this.sessions.delete(ws);
}
// Close gracefully
ws.close(code, reason);
// If no more connections, persist state and prepare for hibernation
if (this.state.getWebSockets().length === 0) {
await this.persistState();
}
}
4. Leverage R2 for Large Data
Don’t store large files in Durable Object storage—use R2:
async saveWorkspaceFile(filename: string, content: ArrayBuffer): Promise<void> {
const workspaceId = 'workspace-123';
// Store large file in R2
await this.env.R2_BUCKET.put(
`workspaces/${workspaceId}/${filename}`,
content
);
// Store metadata in Durable Object
await this.state.storage.put(`file:${filename}`, {
path: filename,
size: content.byteLength,
uploadedAt: Date.now()
});
}
Performance Considerations
Cold Starts
Durable Objects have minimal cold start overhead:
- First request: ~50-200ms (load from disk)
- Subsequent requests: <1ms (in-memory)
Optimize initialization:
constructor(state: DurableObjectState) {
this.state = state;
// Only block if absolutely necessary
this.state.blockConcurrencyWhile(async () => {
// Load critical data only
this.userId = await this.state.storage.get('userId');
});
// Load less critical data asynchronously
this.loadHistoryAsync();
}
private async loadHistoryAsync(): Promise<void> {
this.messageHistory = await this.state.storage.get('messages') || [];
}
Memory Limits
Each Durable Object has a 128 MB memory limit. Design accordingly:
// Good: Keep limited history in memory
if (this.messages.length > 1000) {
this.messages = this.messages.slice(-1000);
}
// Bad: Unbounded growth
this.allMessagesEver.push(msg); // ❌ Will eventually OOM
Cost Model
Durable Objects pricing (2025):
- Requests: $0.15 per million requests
- Duration: $12.50 per million GB-seconds
- Storage: $0.20 per GB-month
For a chat room with 100 active users, 10 messages/min:
- ~43,000 requests/month → $0.006
- ~2 GB-seconds → $0.025
- ~10 MB storage → $0.002 Total: ~$0.033/month per room
Compare to running a dedicated WebSocket server!
Conclusion
Durable Objects represent a fundamental shift: from stateless edge computing to stateful systems with strong consistency. By making each object the single source of truth for its data and automatically migrating it closer to users, Cloudflare eliminates the complexity of distributed state management.
The programming model feels like writing a class that runs on a single server—no locks, no distributed transactions, no eventual consistency headaches. But under the hood, Cloudflare handles global distribution, automatic failover, and transparent migration.
Key principles:
- Design single-tenant - One object per logical entity (user, room, session)
- Embrace WebSockets - Build real-time features naturally without external pub/sub
- Use transactions - Leverage built-in ACID guarantees for complex state changes
- Combine with R2/KV - Offload large/cold data, keep hot state in Durable Objects
- Monitor memory - Keep state bounded (128MB limit per object)
At Underscore.is, Durable Objects enabled us to build a truly global, real-time coding platform without managing infrastructure. Each user workspace is a Durable Object that persists state, coordinates AI agents, and handles WebSocket connections—all automatically distributed to the nearest edge location. The operational simplicity is remarkable.
The future of edge computing is stateful, and Durable Objects show how to do it right.
Further Resources:
- Durable Objects Documentation - Comprehensive guides
- Durable Objects Announcement - Original vision
- Durable Objects Examples - Code samples
- Storage API Reference - Transactional storage
- WebSocket API - WebSocket support
- Hibernatable WebSockets - Cost optimization
- Durable Objects Pricing - Cost model
| *Posted on January 28, 2025 | Tags: Cloudflare, Durable Objects, Edge, State Management | Category: Architecture* |