Durable Objects - Building Stateful Applications at the Edge
Master Cloudflare Durable Objects for building real-time collaborative applications, rate limiting, WebSockets, and distributed state management at the edge.
Introduction
Durable Objects represent a paradigm shift in how we build stateful applications at the edge. While traditional serverless functions are stateless, Cloudflare Durable Objects provide strong consistency, coordination, and persistent state with sub-millisecond latency across a globally distributed network.
This post explores how Durable Objects solve the state management problem for edge computing and why they're revolutionary for building real-time applications, collaborative tools, and distributed systems.
The State Management Problem
Traditional Serverless Limitations
Traditional serverless architectures face challenges when applications need state:
The Dilemma:
Stateless Functions + External Database = High Latency & Database Bottleneck
↓ ↓
Fast execution Slow reads/writes
Consistency issues
Cost scaling
- Database Round Trips: Each state operation adds 50-200ms latency
- Consistency Challenges: Distributed systems are hard to coordinate
- Scaling Issues: Database becomes the bottleneck
- Cost Multiplication: Database charges stack on top of compute costs
Why Edge State is Different
Durable Objects solve this by bringing state to the computation rather than computation to the state:
Traditional: Request → Function → Database → Response (200ms+)
Durable Objects: Request → Isolate with State → Response (1-5ms)
What Are Durable Objects?
Durable Objects are persistent, strongly consistent objects that live on Cloudflare's edge network. Each object:
- Lives Globally: Automatically geo-routed to the nearest location
- Maintains State: In-memory state with persistent storage
- Provides Coordination: Strong consistency for shared state
- Scales Instantly: No provisioning or cold starts
- Costs Predictably: $0.15 per million requests + $5 per GB-month storage
Core Characteristics
| Feature | Traditional DB | Durable Objects |
|---|---|---|
| Latency | 50-200ms | 1 to 5ms |
| Consistency | Eventual | Strong |
| Geo-distribution | Single region | Global edges |
| Cold starts | N/A | Never |
| Coordination | Complex | Built-in |
| Cost per request | $0.01-0.10 | $0.00015 |
How Durable Objects Work
The Architecture
User Request
↓
Nearest Cloudflare Edge
↓
Route to Durable Object (by ID)
↓
Object receives request
↓
Process with state
↓
Return response
Key Point: The Durable Object is always in the same location (routed by ID). All requests for the same object go to the same instance, ensuring strong consistency.
Creating Your First Durable Object
// durable_objects/counter.js
export class Counter {
constructor(state, env) {
this.state = state;
this.env = env;
}
async fetch(request) {
const url = new URL(request.url);
const count = (await this.state.get('count')) || 0;
if (request.method === 'POST') {
const newCount = count + 1;
await this.state.put('count', newCount);
return new Response(JSON.stringify({ count: newCount }));
}
return new Response(JSON.stringify({ count }));
}
}
export default Counter;Binding in wrangler.toml
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
script_name = "my-worker"
[[env.production.durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
[[migrations]]
tag = "v1"
new_classes = ["Counter"]Real-World Use Cases
1. Real-Time Collaborative Editing
Imagine Google Docs but running at the edge:
export class Document {
constructor(state, env) {
this.state = state;
this.connections = new Set();
}
async fetch(request) {
// WebSocket support
if (request.headers.get('Upgrade') === 'websocket') {
const pair = new WebSocketPair();
this.connections.add(pair[1]);
pair[1].accept();
// Handle incoming edits
pair[1].addEventListener('message', async (msg) => {
const edit = JSON.parse(msg.data);
// Persist edit
const edits = (await this.state.get('edits')) || [];
edits.push(edit);
await this.state.put('edits', edits);
// Broadcast to all connections
for (const conn of this.connections) {
conn.send(JSON.stringify(edit));
}
});
return new Response(null, { status: 101, webSocket: pair[0] });
}
// Get current document state
const edits = (await this.state.get('edits')) || [];
return new Response(JSON.stringify({ edits }));
}
}Performance Benefits:
- Edits broadcast instantly (sub-millisecond latency)
- No database round trips per keystroke
- Strong consistency - all users see same version
- Global users connected to nearest object instance
2. Rate Limiting & Quota Management
export class RateLimiter {
constructor(state) {
this.state = state;
}
async fetch(request) {
const userId = request.headers.get('user-id');
const key = `quota:${userId}`;
const quota = (await this.state.get(key)) || {
requests: 0,
reset: Date.now() + 60000 // 1 minute
};
if (Date.now() > quota.reset) {
quota.requests = 0;
quota.reset = Date.now() + 60000;
}
if (quota.requests >= 100) {
return new Response('Rate limited', { status: 429 });
}
quota.requests++;
await this.state.put(key, quota);
return new Response('OK', { status: 200 });
}
}Advantages:
- Precise per-user rate limiting
- No external service calls
- Instant decision making
- sub-millisecond overhead
3. Real-Time Analytics & Counters
export class Analytics {
constructor(state) {
this.state = state;
}
async fetch(request) {
const { pathname } = new URL(request.url);
const event = pathname.split('/')[1];
if (request.method === 'POST') {
// Increment counter
const count = (await this.state.get(event)) || 0;
await this.state.put(event, count + 1);
return new Response('tracked');
}
// Get analytics
const stats = {};
const keys = await this.state.list();
for (const key of keys.keys) {
stats[key.name] = await this.state.get(key.name);
}
return new Response(JSON.stringify(stats));
}
}Performance:
- Counters updated instantly (no batch delays)
- Real-time analytics without database
- Consistent reads and writes
- Scales to millions of events per second
4. Session Management
export class SessionManager {
constructor(state) {
this.state = state;
}
async fetch(request) {
const { pathname } = new URL(request.url);
const sessionId = pathname.split('/')[1];
if (request.method === 'POST') {
const sessionData = await request.json();
await this.state.put(`session:${sessionId}`, sessionData, {
expiration: Math.floor(Date.now() / 1000) + 86400 // 24 hours
});
return new Response('session created');
}
const session = await this.state.get(`session:${sessionId}`);
return new Response(JSON.stringify(session || {}));
}
}Benefits:
- Sessions never lost (persistent storage)
- Global availability
- sub-millisecond lookup times
- Automatic TTL-based cleanup
Architecture Patterns
Pattern 1: Master Coordination
Use a Durable Object as a master coordinator:
export class Coordinator {
constructor(state, env) {
this.state = state;
this.env = env;
}
async fetch(request) {
const { pathname } = new URL(request.url);
if (pathname === '/lock') {
const lockName = request.headers.get('lock-name');
const locked = await this.state.get(`lock:${lockName}`);
if (locked) {
return new Response('locked', { status: 409 });
}
await this.state.put(`lock:${lockName}`, true, {
expirationTtl: 30 // 30 second timeout
});
return new Response('acquired');
}
return new Response('coordinator ready');
}
}Pattern 2: Event Sourcing
export class EventLog {
constructor(state) {
this.state = state;
}
async fetch(request) {
if (request.method === 'POST') {
const event = await request.json();
// Append to event log
const events = (await this.state.get('events')) || [];
events.push({
...event,
timestamp: Date.now()
});
await this.state.put('events', events);
return new Response(JSON.stringify(event));
}
// Get all events
const events = (await this.state.get('events')) || [];
return new Response(JSON.stringify(events));
}
}Pattern 3: Distributed Cache
export class DistributedCache {
constructor(state, env) {
this.state = state;
this.env = env;
}
async fetch(request) {
const key = request.headers.get('cache-key');
const cached = await this.state.get(key);
if (cached) {
return new Response(cached);
}
// Fetch from origin
const response = await fetch(this.env.ORIGIN_URL + key);
const data = await response.text();
// Cache for 1 hour
await this.state.put(key, data, { expirationTtl: 3600 });
return new Response(data);
}
}Performance Characteristics
Latency Comparison
Operation | Database | Durable Objects
---------------------------|----------|----------------
Single read | 50 to 100ms | 1 to 2ms
Single write | 50 to 100ms | 1 to 2ms
Rate limit check | 50 to 100ms | less than 1ms
Transaction | 100 to 300ms | 2 to 5ms
Broadcast to 1000 clients | 5 to 10 seconds | 100 to 500ms
Consistency Model
Durable Objects guarantee:
- Strong consistency: All operations are serialized
- Atomicity: Operations complete fully or not at all
- Durability: Data survives edge restarts
- No race conditions: Request order is guaranteed
Storage and Persistence
In-Memory State
// Fast but limited
const data = await this.state.get('key');
await this.state.put('key', value);Characteristics:
- Sub-millisecond access
- Lost if object is restarted
- Limited by available RAM (128 MB per object)
Persistent Storage (durable_location)
// Survives restarts
await this.state.put('key', value); // Auto-persistedCharacteristics:
- Automatically replicated
- $5 per GB-month storage cost
- Slightly higher latency (still under 10ms)
- Survives edge failures
Choosing Storage
Use In-Memory State For:
- Cache data (can be regenerated)
- Session data (short-lived)
- WebSocket connections
- Real-time counters
Use Persistent Storage For:
- Financial transactions
- User account data
- Event logs
- Audit trails
Scaling Considerations
When to Use Multiple Objects
// Bad: Single object for all users
const obj = env.SHARED_OBJECT.get('global');
// Good: One object per user
const obj = env.USERS.get(userId);
// Good: One object per room/session
const obj = env.ROOMS.get(roomId);Principle: Each Durable Object is a bottleneck. Distribute load across multiple objects by ID.
Recommended Patterns
Users: 1,000 to 10,000 per object
Events: 1 to 10 million per second per object
Connections: 1,000 to 10,000 per object
State size: 1 to 50 MB per object
Cost Analysis
Pricing Breakdown
Durable Object Requests: $0.15 per million
Storage: $5 per GB-month
Example workload:
- 10 million requests/month
- 100 MB storage
= (10M × $0.15 / 1M) + (0.1 × $5)
= $1.50 + $0.50 = $2/month
Comparison with Databases
Traditional Database:
- Simple read: $0.00001 per request × 10M = $100
- Storage: $0.50 per GB × 0.1 GB = $50
- Total: $150/month
Durable Objects:
- 10M requests: $1.50
- 100MB storage: $0.50
- Total: $2/month
Savings: 75x cheaper!
Best Practices
1. Design for Locality
// Route requests to nearest object
const userId = request.headers.get('user-id');
const obj = env.USERS.get(userId); // Consistent routing
return obj.fetch(request);2. Handle Errors Gracefully
export class SafeObject {
async fetch(request) {
try {
const data = await this.state.get('key');
return new Response(JSON.stringify(data));
} catch (e) {
// Fallback behavior
return new Response(
JSON.stringify({ error: 'Service unavailable' }),
{ status: 503 }
);
}
}
}3. Implement Alarms for Cleanup
export class CleanupObject {
constructor(state, env) {
this.state = state;
this.env = env;
}
async alarm() {
// Cleanup expired data
const now = Date.now();
const keys = await this.state.list();
for (const key of keys.keys) {
const item = await this.state.get(key.name);
if (item.expiration < now) {
await this.state.delete(key.name);
}
}
}
}4. Monitor Object Usage
// Track metrics
const requests = (await this.state.get('metrics:requests')) || 0;
await this.state.put('metrics:requests', requests + 1);
// Set alarms for high usage
if (requests > 1000000) {
// Consider splitting object
}Advanced Features
WebSocket Support
// Handle real-time bidirectional communication
const pair = new WebSocketPair();
pair[1].accept();
// Echo server
pair[1].addEventListener('message', msg => {
pair[1].send(msg.data);
});
return new Response(null, { status: 101, webSocket: pair[0] });Scheduled Alarms
// Run tasks at specific times
export class ScheduledObject {
async fetch(request) {
const nextAlarm = new Date(Date.now() + 60000); // 1 minute
await this.state.setAlarm(nextAlarm);
return new Response('alarm scheduled');
}
async alarm() {
// Runs automatically at scheduled time
console.log('Alarm triggered!');
}
}Conclusion
Durable Objects revolutionize edge computing by solving the state management problem:
- Global Coordination: Consistent state across 300+ edge locations
- Instant Latency: sub-millisecond response times without database
- Strong Consistency: ACID-like guarantees built-in
- Cost Effective: 75x cheaper than traditional databases
- Scalable: Handle millions of operations per second
- Easy to Use: Simple JavaScript API with automatic persistence
Whether you're building collaborative apps, real-time systems, or distributed coordination, Durable Objects provide the infrastructure to build at scale with sub-millisecond latency.
Quick Start Checklist
- Define your Durable Object class with
fetch()andalarm()methods - Bind the object in
wrangler.toml - Route requests by object ID for consistency
- Use persistent storage for critical data
- Monitor object usage and split if needed
- Test WebSocket connections for real-time features
- Deploy and scale instantly
Start building stateful edge applications with Durable Objects today!