Cloudflare Workers + Durable Objects — Building Stateful Serverless Apps on the Edge
Posted on: 4/26/2026 11:10:00 AM
Table of contents
- 1. The State Problem in Serverless
- 2. Cloudflare Workers — The Edge Computing Platform
- 3. Durable Objects — The Actor Model on the Edge
- 4. SQLite Storage — Zero-Latency Embedded Database
- 5. Architecture Guide: When to Use Workers, KV, D1, or DO?
- 6. Durable Objects Facets — New in 2026
- 7. Common Use Cases
- 8. Deployment and Configuration
- 9. Pricing and Free Tier
- 10. Comparison with Alternatives
- 11. Best Practices
- References
1. The State Problem in Serverless
Serverless has revolutionized application deployment: no server management, automatic scaling, pay-per-request pricing. But there's an inherent limitation — serverless is fundamentally stateless. Each request is handled by a completely independent instance with no knowledge of previous requests.
This makes many real-world problems extremely complex: collaborative editing (Google Docs), multiplayer games, chat rooms, rate limiting, or any application requiring shared state across multiple clients. The traditional solution is adding an external database or cache layer — but that contradicts the "zero infrastructure" philosophy of serverless.
Cloudflare Workers + Durable Objects solves this elegantly: combining compute and storage within a single unit, running across 300+ global data centers, with near-zero latency between processing logic and data.
2. Cloudflare Workers — The Edge Computing Platform
Cloudflare Workers is a serverless platform running JavaScript/TypeScript on Cloudflare's edge network. Instead of code executing in a single region (us-east-1, ap-southeast-1...), Workers execute at the data center nearest to the user — minimizing latency.
graph LR
A[User Vietnam] -->|Request| B[Edge Hanoi]
C[User US] -->|Request| D[Edge New York]
E[User EU] -->|Request| F[Edge Frankfurt]
B --> G[Worker Instance]
D --> H[Worker Instance]
F --> I[Worker Instance]
G --> J[(KV / D1 / DO)]
H --> J
I --> J
Workers use V8 isolates (the same engine powering Chrome) instead of containers or VMs. Each request runs in a separate isolate — starting in microseconds instead of the milliseconds typical of Lambda/Cloud Functions.
Basic Worker Example
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/api/hello") {
return Response.json({
message: "Hello from the Edge!",
location: request.cf?.city,
country: request.cf?.country
});
}
return fetch(request);
}
};
V8 Isolates Advantage
V8 Isolates enable Workers to achieve ~0ms cold start (vs. 100-500ms for AWS Lambda), use 10-100x less memory than containers, and run thousands of isolates within a single process. This is why Workers pricing is significantly cheaper than traditional FaaS platforms.
3. Durable Objects — The Actor Model on the Edge
Durable Objects (DO) is the most important Workers extension, fundamentally solving the state problem. Each Durable Object is a globally single instance with a unique identifier and an embedded SQLite database.
If you're familiar with the Actor Model (Akka, Erlang, Microsoft Orleans), Durable Objects is an edge implementation of that pattern: each actor processes messages sequentially, has its own state, and its lifecycle is managed automatically.
graph TB
subgraph "Durable Object: chat-room-42"
DO[DO Instance] --> DB[(SQLite DB)]
DO --> MEM[In-Memory State]
DO --> WS[WebSocket Sessions]
end
U1[Client 1] -->|WebSocket| DO
U2[Client 2] -->|WebSocket| DO
U3[Client 3] -->|WebSocket| DO
W1[Worker Edge HN] -->|stub.fetch| DO
W2[Worker Edge SG] -->|stub.fetch| DO
Three Core Properties
| Property | Description | Practical Implication |
|---|---|---|
| Global Uniqueness | Each DO has a system-wide unique ID | Send requests to the exact object from anywhere in the world |
| Single-threaded | All requests to a DO are processed sequentially | No locks, mutexes, or race conditions |
| Co-located Storage | SQLite database resides with the compute | Zero-latency between code and data, strong consistency |
Example: Chat Room with Durable Objects
// Worker entry point — route to Durable Object
export default {
async fetch(request, env) {
const url = new URL(request.url);
const roomId = url.searchParams.get("room") || "default";
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
return stub.fetch(request);
}
};
// Durable Object class
export class ChatRoom {
constructor(state, env) {
this.state = state;
this.sessions = [];
}
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
this.state.acceptWebSocket(server);
const history = this.state.storage.sql.exec(
"SELECT sender, message, timestamp FROM messages ORDER BY timestamp DESC LIMIT 50"
).toArray().reverse();
server.send(JSON.stringify({ type: "history", data: history }));
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws, msg) {
const data = JSON.parse(msg);
this.state.storage.sql.exec(
"INSERT INTO messages (sender, message, timestamp) VALUES (?, ?, ?)",
data.sender, data.message, Date.now()
);
for (const session of this.state.getWebSockets()) {
session.send(JSON.stringify({
type: "message",
sender: data.sender,
message: data.message
}));
}
}
async webSocketClose(ws) {
ws.close();
}
}
WebSocket Hibernation
Durable Objects support WebSocket Hibernation — when no messages are being sent/received, the DO can "hibernate" to save resources while maintaining WebSocket connections. When a new message arrives, the DO automatically wakes up. This enables maintaining thousands of concurrent WebSocket connections at near-zero cost when idle.
4. SQLite Storage — Zero-Latency Embedded Database
Since 2025, Cloudflare transitioned Durable Objects Storage to use SQLite as the primary backend instead of the previous key-value store. This is a major advancement: you get a full relational database inside each Durable Object without connecting to any external database service.
| Feature | KV Storage (Legacy) | SQLite Storage (Current) |
|---|---|---|
| Query language | get/put/delete/list | Full SQL (SELECT, JOIN, INDEX...) |
| Max capacity | ~50MB | 10GB per object |
| Transactions | No | ACID transactions |
| Indexing | Key prefix only | Custom B-tree indexes |
| Consistency | Strong | Serializable isolation |
| Backup | Manual export | Point-in-time recovery |
// Initialize schema in constructor
async initialize() {
this.state.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
data TEXT,
expires_at INTEGER,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_sessions_user
ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires
ON sessions(expires_at);
`);
}
// Query with prepared statements
getActiveSessions(userId) {
return this.state.storage.sql.exec(
`SELECT * FROM sessions
WHERE user_id = ? AND expires_at > ?`,
userId, Date.now()
).toArray();
}
5. Architecture Guide: When to Use Workers, KV, D1, or DO?
Cloudflare provides multiple storage options, each suited to different use cases. Choosing the right solution is the most important architectural decision.
graph TB
REQ[Incoming Request] --> W[Worker]
W --> |"Static config, cache"| KV[(KV Store)]
W --> |"Relational queries, analytics"| D1[(D1 Database)]
W --> |"Stateful logic, realtime"| DO[Durable Objects]
W --> |"Large files, media"| R2[(R2 Storage)]
DO --> |"Per-object data"| SQL[(Embedded SQLite)]
KV -.-> |"Eventually consistent"| KV
D1 -.-> |"Strongly consistent"| D1
DO -.-> |"Single-instance consistent"| DO
| Solution | Consistency | Primary Use Case | Free Tier |
|---|---|---|---|
| KV | Eventually consistent | Config, cache, feature flags | 100K reads/day |
| D1 | Strong (single-region) | CRUD apps, relational data | 5M row reads/day |
| Durable Objects | Strong (single-instance) | Realtime, coordination, sessions | Available (limited) |
| R2 | Strong | Object storage, file uploads | 10GB storage |
When NOT to Use Durable Objects
DO isn't a silver bullet. Don't use DO for: read-heavy workloads without coordination needs (use KV), complex relational queries across multiple entities (use D1), or static content serving (use R2 + Cache). DO excels when you need coordination among multiple clients on the same entity.
6. Durable Objects Facets — New in 2026
Facets is the latest Durable Objects feature, enabling dynamically loaded code to manage persistent state. Instead of each Durable Object having a single class handling all logic, Facets lets you split logic into independent modules — each facet managing its own state partition.
This is particularly important for AI Agents: an agent can have separate facets for memory, tool execution, and conversation history — each developed and deployed independently.
// Facet for AI Agent memory
export class AgentMemoryFacet {
constructor(state) {
this.state = state;
state.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT,
embedding BLOB,
created_at INTEGER
)
`);
}
async store(memory) {
this.state.storage.sql.exec(
"INSERT INTO memories (id, content, created_at) VALUES (?, ?, ?)",
crypto.randomUUID(), memory.content, Date.now()
);
}
async recall(query, limit = 10) {
return this.state.storage.sql.exec(
"SELECT * FROM memories ORDER BY created_at DESC LIMIT ?",
limit
).toArray();
}
}
7. Common Use Cases
7.1 Distributed Rate Limiter
export class RateLimiter {
constructor(state) {
this.state = state;
state.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS requests (
ip TEXT, timestamp INTEGER
);
CREATE INDEX IF NOT EXISTS idx_ip_ts ON requests(ip, timestamp);
`);
}
async fetch(request) {
const ip = request.headers.get("CF-Connecting-IP");
const windowMs = 60000;
const maxRequests = 100;
const now = Date.now();
this.state.storage.sql.exec(
"DELETE FROM requests WHERE timestamp < ?", now - windowMs
);
const count = this.state.storage.sql.exec(
"SELECT COUNT(*) as cnt FROM requests WHERE ip = ? AND timestamp > ?",
ip, now - windowMs
).one().cnt;
if (count >= maxRequests) {
return new Response("Rate limit exceeded", { status: 429 });
}
this.state.storage.sql.exec(
"INSERT INTO requests (ip, timestamp) VALUES (?, ?)", ip, now
);
return new Response("OK", {
headers: { "X-RateLimit-Remaining": String(maxRequests - count - 1) }
});
}
}
7.2 Distributed Counter (E-commerce Flash Sale)
export class InventoryCounter {
constructor(state) {
this.state = state;
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/reserve") {
const current = (await this.state.storage.get("stock")) || 0;
if (current <= 0) {
return Response.json({ success: false, reason: "out_of_stock" });
}
await this.state.storage.put("stock", current - 1);
return Response.json({ success: true, remaining: current - 1 });
}
if (url.pathname === "/stock") {
const stock = (await this.state.storage.get("stock")) || 0;
return Response.json({ stock });
}
}
}
8. Deployment and Configuration
Configure Workers + Durable Objects via wrangler.toml:
# wrangler.toml
name = "my-app"
main = "src/index.ts"
compatibility_date = "2026-04-01"
[durable_objects]
bindings = [
{ name = "CHAT_ROOM", class_name = "ChatRoom" },
{ name = "RATE_LIMITER", class_name = "RateLimiter" }
]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom", "RateLimiter"]
[[kv_namespaces]]
binding = "CACHE"
id = "abc123"
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xyz789"
Deploy with Wrangler CLI:
# Install
npm install -g wrangler
# Login
wrangler login
# Local dev
wrangler dev
# Deploy to production
wrangler deploy
CI/CD with GitHub Actions
Cloudflare provides cloudflare/wrangler-action@v3 for automated deployment. Simply add CLOUDFLARE_API_TOKEN to GitHub Secrets and configure the workflow — each push to main automatically deploys to production.
9. Pricing and Free Tier
| Component | Free Tier | Paid (Workers Paid $5/month) |
|---|---|---|
| Workers Requests | 100K requests/day | 10M requests/month (then $0.30/M) |
| Workers CPU Time | 10ms CPU/request | 30s CPU/request |
| DO Requests | Available (limited) | 1M requests/month (then $0.15/M) |
| DO Storage | Available (limited) | 1GB included (then $0.20/GB) |
| DO Duration | Available (limited) | 400K GB-s/month |
| KV | 100K reads/day | 10M reads/month |
| D1 | 5M row reads/day | 25B row reads/month |
| R2 | 10GB storage | 10GB free, $0.015/GB after |
Cost Comparison with AWS
A realtime chat application with 10K concurrent users on AWS requires: API Gateway WebSocket ($1/M messages) + Lambda ($0.20/M requests) + DynamoDB ($1.25/M writes) + ElastiCache. On Cloudflare: Workers + Durable Objects at 3-5x lower cost, no infrastructure management, and lower latency thanks to edge computing.
10. Comparison with Alternatives
| Criteria | CF Workers + DO | AWS Lambda + DynamoDB | Azure Functions + Cosmos DB |
|---|---|---|---|
| Cold start | ~0ms | 100-500ms | 200-1000ms |
| Edge locations | 300+ | 30+ regions | 60+ regions |
| Stateful compute | Native (DO) | External (Step Functions) | External (Durable Functions) |
| WebSocket | Native + Hibernation | API Gateway WebSocket | SignalR Service |
| Embedded DB | SQLite/object | No | No |
| Free tier | Very generous | Good | Good |
| Vendor lock-in | Medium | High | High |
11. Best Practices
Design Durable Objects Properly
1 DO = 1 logical entity. Each chat room is 1 DO, each user session is 1 DO, each game match is 1 DO. Don't create a "god" DO handling everything — that creates a bottleneck since DOs are single-threaded. Think of each DO as a microservice with its own state.
Architecture Checklist:
- Partition by entity: each Durable Object represents one business entity (user, room, order)
- Use Alarms for background work: instead of polling, use
state.storage.setAlarm()to schedule future tasks - WebSocket Hibernation: always use
state.acceptWebSocket()instead of manual WebSocket management — significantly reduces billable duration - Batch writes: group multiple storage operations into a single transaction to reduce I/O
- Graceful migration: use migration tags in wrangler.toml when changing storage schemas
- Error boundary: always wrap logic in try-catch as uncaught exceptions will reset DO in-memory state
References
- Cloudflare Durable Objects Documentation — Cloudflare Docs
- Cloudflare Workers Documentation — Cloudflare Docs
- Workers Durable Objects Beta: A New Approach to Stateful Serverless — Cloudflare Blog
- Build stateful apps with Durable Objects — Cloudflare
- Edge Computing with Cloudflare Workers: Complete Guide 2026 — CalmOps
Debugger Agent in Visual Studio 2026 — When AI Debugs Your Code For You
Progressive Web Apps 2026: Offline-First with Service Worker, Workbox and Vue.js
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.