Cloudflare Workers Architecture Explained
Cloudflare Workers Architecture Explained
Introduction
Cloudflare Workers isn't just "serverless at the edge." It's a fundamentally different execution model that challenges assumptions baked into a decade of container-based cloud computing. Understanding its architecture isn't optional for production use—the constraints and capabilities of isolate-based execution directly shape what you can and cannot build.
This deep dive examines Workers from the metal up: how requests route through Cloudflare's network, how V8 isolates provide multi-tenant isolation, how the runtime schedules execution across CPU budget constraints, and how primitives like KV, Durable Objects, and R2 extend what's possible at the edge.
If you're deploying Workers at scale, you need to understand why cold starts happen when they do, why some workloads perform poorly despite low latency numbers, and how to architect systems that play to Workers' strengths while working around its fundamental limitations.
Scale Context
Cloudflare Workers operates at a scale that shapes architectural decisions:
| Metric | Value |
|---|---|
| Edge PoPs | 300+ cities globally |
| Requests Handled Daily | 50+ billion |
| Typical Cold Start | 5ms (P50), 50ms (P99) |
| CPU Time Limit (Free) | 10ms |
| CPU Time Limit (Paid) | 30s (with increased limits) |
| Memory Limit | 128MB per isolate |
| Script Size Limit | 10MB (after compression) |
| Concurrent Connections | 6 per request to origin |
| Subrequest Limit | 50 per request (1000 with flag) |
| Workers Deployed Globally | Millions |
| V8 Isolates per Server | 10,000+ |
These constraints aren't arbitrary—they're calculated tradeoffs enabling multi-tenant density on shared infrastructure.
Network Architecture
Global Anycast Network
Every Cloudflare PoP announces the same IP addresses via BGP Anycast. When a user makes a request, BGP routing naturally directs them to the nearest PoP based on network topology.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLOUDFLARE ANYCAST NETWORK │
│ │
│ User Request (Mumbai) ──────────────────────────────────────────────────┐ │
│ │ │
│ ┌──────────────────────────────────────────────────────────────────┐ │ │
│ │ BGP ROUTING DECISION │ │ │
│ │ │ │ │
│ │ ISP Router (Mumbai) receives routes for 104.16.x.x from: │ │ │
│ │ • Mumbai PoP: AS Path length 1, local preference high │◀──┘ │
│ │ • Singapore PoP: AS Path length 2 │ │
│ │ • Frankfurt PoP: AS Path length 4 │ │
│ │ │ │
│ │ Decision: Route to Mumbai PoP (shortest path, highest pref) │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ MUMBAI PoP │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Router │ │ L4 LB │ │ Server │ │ │
│ │ │ (BGP) │──│ (Unimog) │──│ Cluster │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ │ ┌──────┴──────┐ │ │
│ │ ▼ ▼ │ │
│ │ [Server 1] [Server 2] │ │
│ │ (Workers) (Workers) │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Request Flow Through the Stack
sequenceDiagram
participant C as Client
participant R as Router (BGP)
participant L as L4 LB (Unimog)
participant P as HTTP Proxy (FL)
participant W as Workers Runtime
participant O as Origin (if needed)
C->>R: TCP SYN to 104.16.x.x
R->>L: Forward based on Anycast
L->>P: ECMP hash to server
Note over P: TLS termination<br/>HTTP parsing<br/>Request routing
P->>W: Execute Worker script
activate W
alt Cache Hit
W->>P: Cached response
else Worker Logic
W->>W: V8 Isolate execution
opt Subrequest needed
W->>O: fetch() to origin
O->>W: Response
end
W->>P: Generated response
end
deactivate W
P->>C: HTTP Response
Unimog: L4 Load Balancing
Cloudflare's L4 load balancer (Unimog) uses a combination of ECMP (Equal-Cost Multi-Path) and consistent hashing to distribute connections across servers within a PoP.
┌─────────────────────────────────────────────────────────────────────────────┐
│ UNIMOG ARCHITECTURE │
│ │
│ Incoming Connection ──▶ ┌────────────────────────────────────────┐ │
│ │ ECMP HASH │ │
│ │ │ │
│ │ hash = H(src_ip, dst_ip, src_port, │ │
│ │ dst_port, protocol) │ │
│ │ │ │
│ │ server_index = hash % num_servers │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ [Server 0] [Server 1] [Server 2] │
│ │
│ Connection Stickiness: │
│ • Same 5-tuple always routes to same server │
│ • Server failure: rehash to healthy server │
│ • Connection migration via TCP handoff (rare) │
│ │
│ Health Checking: │
│ • Passive: Track connection failures │
│ • Active: Periodic L7 health checks │
│ • Removal: Fast (seconds), gradual connection drain │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
V8 Isolate Architecture
Why Isolates, Not Containers
The fundamental innovation in Workers is using V8 isolates instead of containers for tenant isolation. This enables density that containers can't match.
| Aspect | Container | V8 Isolate |
|---|---|---|
| Startup Time | 500ms - 5s | 5-50ms |
| Memory Overhead | 50-200MB | 1-5MB |
| Process Isolation | Full (separate process, namespaces) | V8 sandbox (same process) |
| File System | Full (overlayfs) | None (by design) |
| Network Stack | Full | fetch() API only |
| Density | 10-50 per server | 10,000+ per server |
| Scheduling | OS scheduler | Event loop + time slicing |
The Security Model:
V8 isolates provide security through the V8 JavaScript sandbox—the same isolation model that protects your browser when you visit untrusted websites. Each isolate:
- Has its own JavaScript heap (memory isolation)
- Cannot access other isolates' memory directly
- Has no filesystem access
- Has no raw network socket access
- Has strictly controlled APIs (Web Workers standard subset)
┌─────────────────────────────────────────────────────────────────────────────┐
│ WORKERS RUNTIME PROCESS │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ V8 ENGINE │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Isolate A │ │ Isolate B │ │ Isolate C │ │ Isolate D │ │ │
│ │ │ (tenant1) │ │ (tenant2) │ │ (tenant1) │ │ (tenant3) │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ │
│ │ │ │ Heap │ │ │ │ Heap │ │ │ │ Heap │ │ │ │ Heap │ │ │ │
│ │ │ │ (128MB) │ │ │ │ (128MB) │ │ │ │ (128MB) │ │ │ │ (128MB) │ │ │ │
│ │ │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ │
│ │ │ │ Context │ │ │ │ Context │ │ │ │ Context │ │ │ │ Context │ │ │ │
│ │ │ │(globals)│ │ │ │(globals)│ │ │ │(globals)│ │ │ │(globals)│ │ │ │
│ │ │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ SHARED RESOURCES │ │ │
│ │ │ │ │ │
│ │ │ [Code Cache] [JIT Compiler] [GC Threads] [Platform APIs] │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ RUNTIME SERVICES │ │
│ │ │ │
│ │ [Event Loop] [I/O Thread Pool] [Crypto HW] [KV Client] [DO Stub] │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Isolate Lifecycle
stateDiagram-v2
[*] --> Cold: First request to script
Cold --> Initializing: Create isolate
Initializing --> Running: Script loaded & parsed
Running --> Running: Handle requests
Running --> Idle: No pending requests
Idle --> Running: New request
Idle --> Evicted: Memory pressure / TTL
Evicted --> Cold: Next request
Running --> Terminated: CPU limit exceeded
Terminated --> Cold: Next request
Cold Start Breakdown:
Cold Start Timeline (~10ms total):
├── Isolate Creation: 1ms
│ └── Allocate V8 isolate struct
│ └── Initialize heap
│ └── Create execution context
│
├── Script Fetch: 2ms (cached at PoP)
│ └── Load from local script cache
│ └── Decompress if needed
│
├── Parse: 3ms (varies by script size)
│ └── Lexical analysis
│ └── AST construction
│ └── Bytecode generation
│
├── Compile: 2ms (hot paths only)
│ └── Baseline compilation (Sparkplug)
│ └── Turbofan deferred for optimization
│
├── Top-Level Execution: 2ms
│ └── Execute module initialization
│ └── Run top-level statements
│ └── Export handlers
│
└── Ready for Request
Warm Instance Behavior:
Warm Request Timeline (~0.5ms overhead):
├── Event Loop: Accept request
├── Handler Lookup: Find fetch() handler
├── Context Switch: Enter isolate
├── [Your code executes]
├── Context Switch: Exit isolate
└── Response: Send to client
CPU Time Accounting
Workers charges CPU time, not wall-clock time. This distinction is critical.
// CPU time vs wall time example
export default {
async fetch(request: Request): Promise<Response> {
const start = Date.now();
// This uses ~0ms CPU time (I/O wait)
const data = await fetch('https://api.example.com/data');
// This uses actual CPU time
const processed = expensiveComputation(await data.json());
const wallTime = Date.now() - start; // Maybe 200ms
// But CPU time might only be 5ms
return new Response(JSON.stringify(processed));
}
};
// What counts as CPU time:
// ✓ JavaScript execution
// ✓ JSON parsing
// ✓ Regex matching
// ✓ String manipulation
// ✓ Crypto operations (software path)
// What doesn't count:
// ✗ Waiting for fetch()
// ✗ Waiting for KV.get()
// ✗ Waiting for setTimeout()
// ✗ WebSocket idle time
CPU Time Enforcement:
┌─────────────────────────────────────────────────────────────────────────────┐
│ CPU TIME ENFORCEMENT │
│ │
│ Request starts ──▶ CPU timer starts │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ JavaScript Execution │ ◀── Timer running │
│ └─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ await fetch() │ ◀── Timer PAUSED │
│ │ (I/O wait) │ (not charged) │
│ └─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Process response │ ◀── Timer resumed │
│ └─────────────────────────────┘ │
│ │ │
│ CPU time > limit? │
│ / \ │
│ Yes No │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ TERMINATE │ │ Complete │ │
│ │ Request │ │ normally │ │
│ │ (500) │ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Request Handling Model
The Event-Driven Architecture
Workers uses a single-threaded event loop, but the runtime is highly concurrent through async I/O.
// Conceptual model of how Workers handles concurrent requests
class WorkersRuntime {
private isolates: Map<string, V8Isolate> = new Map();
private eventLoop: EventLoop;
async handleRequest(scriptId: string, request: Request): Promise<Response> {
// Get or create isolate for this script
let isolate = this.isolates.get(scriptId);
if (!isolate) {
// Cold start path
isolate = await this.createIsolate(scriptId);
this.isolates.set(scriptId, isolate);
}
// Schedule execution on the event loop
// Multiple requests can be in-flight for same isolate
return this.eventLoop.schedule(() => {
return isolate.runHandler('fetch', request);
});
}
private async createIsolate(scriptId: string): Promise<V8Isolate> {
const script = await this.fetchScript(scriptId);
const isolate = new V8Isolate({
memoryLimit: 128 * 1024 * 1024, // 128MB
cpuLimit: 30000 // 30s max CPU time
});
await isolate.evaluate(script);
return isolate;
}
}
Concurrent Request Handling
A single isolate can handle multiple concurrent requests. This is different from Node.js where you typically have one request per event loop tick.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONCURRENT REQUESTS IN ONE ISOLATE │
│ │
│ Time ──────────────────────────────────────────────────────────────▶ │
│ │
│ Request A: ███░░░░░░░░░███░░░░░░░░░████████ │
│ ↑ ↑ ↑ │
│ CPU await CPU │
│ fetch() │
│ │
│ Request B: ████░░░░░░░░░░░░██████████░░░░░░██ │
│ ↑ ↑ ↑ │
│ CPU await await │
│ KV.get() fetch() │
│ │
│ Request C: ██████████████████░░░░░░░░░░░░░░░░████ │
│ ↑ ↑ │
│ CPU-heavy await │
│ computation fetch() │
│ │
│ Event Loop: Interleaves CPU execution between requests │
│ All I/O waits are cooperative (non-blocking) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
waitUntil() Pattern
waitUntil() allows work to continue after the response is sent. Critical for non-blocking logging, analytics, and cache updates.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const startTime = Date.now();
// Do the actual work
const result = await processRequest(request);
const response = new Response(JSON.stringify(result));
// Schedule background work - doesn't block response
ctx.waitUntil((async () => {
// This runs AFTER response is sent
// But still within CPU time limits
// Log to external service
await fetch('https://logs.example.com/ingest', {
method: 'POST',
body: JSON.stringify({
path: request.url,
duration: Date.now() - startTime,
status: response.status
})
});
// Update cache
await env.CACHE_KV.put(
`cache:${request.url}`,
JSON.stringify(result),
{ expirationTtl: 3600 }
);
// Warm related caches
await Promise.all(
result.relatedIds.map(id =>
fetch(`${env.ORIGIN}/api/items/${id}`)
)
);
})());
// Response sent immediately
// waitUntil work continues in background
return response;
}
};
waitUntil() Execution Model:
┌─────────────────────────────────────────────────────────────────────────────┐
│ waitUntil() EXECUTION │
│ │
│ Request ──────────────────────────────────────────────────────────▶ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Handler Execution │ │
│ │ │ │
│ │ • Process request │ │
│ │ • Generate response │ │
│ │ • ctx.waitUntil() │──── Schedules promise, doesn't wait │
│ │ • return response │ │
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Response Sent │ ◀── Client receives response here │
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ waitUntil() work │ │
│ │ │ │
│ │ • Logging │ │
│ │ • Cache updates │ │
│ │ • Analytics │ │
│ │ • Pre-warming │ │
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ Isolate may be reused or evicted │
│ │
│ Constraints: │
│ • Still subject to CPU time limits │
│ • 30s wall-clock timeout on waitUntil() │
│ • If isolate evicted, waitUntil() aborted │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Workers KV Architecture
Data Model
Workers KV is a globally distributed, eventually consistent key-value store optimized for read-heavy workloads.
┌─────────────────────────────────────────────────────────────────────────────┐
│ WORKERS KV DATA MODEL │
│ │
│ Key-Value Constraints: │
│ ├── Key: Max 512 bytes, UTF-8 string │
│ ├── Value: Max 25MB │
│ ├── Metadata: Max 1KB JSON (indexed, fast access) │
│ └── Namespace: Logical grouping, billing boundary │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NAMESPACE │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: "user:12345" │ │ │
│ │ │ Value: { "name": "Alice", "prefs": {...} } │ │ │
│ │ │ Metadata: { "type": "user", "created": 1699999999 } │ │ │
│ │ │ Expiration: 2024-12-31T23:59:59Z (optional) │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ Key: "config:feature-flags" │ │ │
│ │ │ Value: { "darkMode": true, "newCheckout": false } │ │ │
│ │ │ Metadata: { "version": 42 } │ │ │
│ │ │ Expiration: null (never expires) │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Replication Architecture
KV uses asynchronous replication from a central write store to edge PoPs.
┌─────────────────────────────────────────────────────────────────────────────┐
│ KV REPLICATION ARCHITECTURE │
│ │
│ ┌─────────────────────────┐ │
│ │ CENTRAL STORE │ │
│ │ (Source of Truth) │ │
│ │ │ │
│ │ • Durable storage │ │
│ │ • Write consistency │ │
│ │ • Change log │ │
│ └───────────┬─────────────┘ │
│ │ │
│ Replication Fan-out │
│ (Async, eventually consistent) │
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Edge Cache │ │ Edge Cache │ │ Edge Cache │ │
│ │ (US-East) │ │ (EU-West) │ │ (APAC) │ │
│ │ │ │ │ │ │ │
│ │ • Local copy │ │ • Local copy │ │ • Local copy │ │
│ │ • Fast reads │ │ • Fast reads │ │ • Fast reads │ │
│ │ • ~60s stale │ │ • ~60s stale │ │ • ~60s stale │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ [Workers] [Workers] [Workers] │
│ │
│ Write Path: │
│ Worker.put() ──▶ Central Store ──▶ Ack ──▶ Async replicate to edges │
│ │
│ Read Path: │
│ Worker.get() ──▶ Edge Cache ──▶ Hit? Return : Fetch from central │
│ │
│ Consistency Guarantees: │
│ • Writes visible locally: Immediate │
│ • Writes visible globally: ~60 seconds (typical) │
│ • No read-after-write consistency across PoPs │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
KV Performance Characteristics
| Operation | Latency (Edge Cache Hit) | Latency (Central) | Throughput |
|---|---|---|---|
| get() | 1-5ms | 50-200ms | Very High |
| put() | N/A (always central) | 20-100ms | Limited |
| delete() | N/A (always central) | 20-100ms | Limited |
| list() | 10-50ms | 50-200ms | Limited |
Optimal KV Usage Patterns:
// GOOD: Read-heavy with infrequent writes
// Feature flags, configuration, static content
async function getFeatureFlags(env: Env): Promise<FeatureFlags> {
// Cache hit: 2ms, miss: 100ms
// Acceptable because flags change rarely
return await env.FLAGS_KV.get('flags:v1', 'json');
}
// GOOD: Write-through with TTL
async function cacheApiResponse(env: Env, key: string, fetcher: () => Promise<unknown>) {
const cached = await env.CACHE_KV.get(key, 'json');
if (cached) return cached;
const fresh = await fetcher();
// Don't await - use waitUntil
env.ctx.waitUntil(
env.CACHE_KV.put(key, JSON.stringify(fresh), { expirationTtl: 300 })
);
return fresh;
}
// BAD: High-write workloads
// KV is not designed for this
async function incrementCounter(env: Env, key: string) {
// Race condition! Multiple workers may read same value
const current = parseInt(await env.COUNTERS_KV.get(key) || '0');
await env.COUNTERS_KV.put(key, String(current + 1));
// Use Durable Objects for counters instead
}
// BAD: Session storage with consistency requirements
// User might hit different PoP and see stale session
async function getSession(env: Env, sessionId: string) {
// Could return stale data if session just updated elsewhere
return await env.SESSIONS_KV.get(`session:${sessionId}`, 'json');
}
Durable Objects Architecture
The Consistency Problem
KV provides eventual consistency, which fails for use cases requiring coordination:
- Counters that must be accurate
- Distributed locks
- Real-time collaboration
- Session state that must be current
Durable Objects provide strong consistency by routing all requests for an object to a single instance.
┌─────────────────────────────────────────────────────────────────────────────┐
│ DURABLE OBJECTS CONSISTENCY MODEL │
│ │
│ Traditional Edge (Eventually Consistent): │
│ │
│ User A (Mumbai) ──▶ Edge (Mumbai) ──▶ KV Read ──▶ Value: 100 │
│ User B (London) ──▶ Edge (London) ──▶ KV Read ──▶ Value: 100 │
│ User A ──▶ KV Write (Value: 101) │
│ User B ──▶ KV Read ──▶ Value: 100 (stale!) │
│ │
│ Durable Objects (Strongly Consistent): │
│ │
│ User A (Mumbai) ──────┐ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Durable Object │ │
│ │ (Single Instance) │ ◀── All requests routed here │
│ │ Location: Auto │ │
│ │ │ │
│ │ State: 100 │ │
│ │ ↓ │ │
│ │ State: 101 │ │
│ └─────────────────────┘ │
│ ▲ │
│ │ │
│ User B (London) ──────┘ │
│ │
│ Both users see consistent state because all access is serialized │
│ through single DO instance │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Durable Object Internals
// Durable Object implementation
export class Counter implements DurableObject {
private value: number = 0;
private storage: DurableObjectStorage;
constructor(state: DurableObjectState, env: Env) {
this.storage = state.storage;
// blockConcurrencyWhile ensures initialization completes
// before any fetch() calls are processed
state.blockConcurrencyWhile(async () => {
this.value = (await this.storage.get('value')) || 0;
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
switch (url.pathname) {
case '/increment':
this.value++;
// Transactional write - guaranteed durable before return
await this.storage.put('value', this.value);
return new Response(String(this.value));
case '/get':
return new Response(String(this.value));
case '/batch':
// Atomic batch operations
const ops = await request.json() as { key: string; value: unknown }[];
await this.storage.transaction(async (txn) => {
for (const op of ops) {
await txn.put(op.key, op.value);
}
});
return new Response('OK');
default:
return new Response('Not Found', { status: 404 });
}
}
}
// Accessing Durable Objects from Workers
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Generate deterministic ID from name
const id = env.COUNTER.idFromName('global-counter');
// Get stub to communicate with DO
const stub = env.COUNTER.get(id);
// All requests to this stub go to the same DO instance
const response = await stub.fetch('http://internal/increment');
return response;
}
};
Location and Latency
Durable Objects run in a single location. The first request determines where.
┌─────────────────────────────────────────────────────────────────────────────┐
│ DURABLE OBJECT LOCATION │
│ │
│ First Access Determines Location: │
│ │
│ User in Mumbai ──▶ First request to DO "room:123" │
│ ↓ │
│ DO created in Mumbai (or nearest region) │
│ ↓ │
│ All future requests route to Mumbai │
│ │
│ Latency Implications: │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ DO Instance (Mumbai) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ▲ ▲ ▲ │
│ │ 10ms │ 150ms │ 300ms │
│ │ │ │ │
│ [User Mumbai] [User London] [User San Francisco] │
│ │
│ Location Hints (Optional): │
│ │
│ // Hint that DO should be in Europe │
│ const id = env.ROOM.idFromName('room:123'); │
│ const stub = env.ROOM.get(id, { locationHint: 'eu' }); │
│ │
│ Available hints: 'wnam' | 'enam' | 'weur' | 'eeur' | 'apac' | 'oc' | etc │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Hibernation and Billing
Durable Objects can hibernate to avoid charges when idle.
export class HibernatableRoom implements DurableObject {
private state: DurableObjectState;
private sessions: Map<string, WebSocket> = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
// Restore sessions after hibernation
for (const ws of state.getWebSockets()) {
const metadata = ws.deserializeAttachment();
this.sessions.set(metadata.sessionId, ws);
}
}
async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade') === 'websocket') {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
const sessionId = crypto.randomUUID();
// Attach metadata that survives hibernation
server.serializeAttachment({ sessionId, joinedAt: Date.now() });
// Accept with hibernation enabled
this.state.acceptWebSocket(server);
this.sessions.set(sessionId, server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response('Expected WebSocket', { status: 400 });
}
// Called when WebSocket message received (after hibernation wake)
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const metadata = ws.deserializeAttachment();
console.log(`Message from ${metadata.sessionId}:`, message);
// Broadcast to other sessions
for (const [id, socket] of this.sessions) {
if (id !== metadata.sessionId) {
socket.send(message);
}
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string) {
const metadata = ws.deserializeAttachment();
this.sessions.delete(metadata.sessionId);
}
async webSocketError(ws: WebSocket, error: unknown) {
ws.close(1011, 'Internal error');
}
}
Hibernation Timeline:
┌─────────────────────────────────────────────────────────────────────────────┐
│ HIBERNATION LIFECYCLE │
│ │
│ Active ─────────────────────────────────────────────────────────▶ Time │
│ │ │
│ │ [Processing requests] │
│ │ │ │
│ │ ▼ │
│ │ No activity for ~10 seconds │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────────────────────────────┐ │
│ │ │ HIBERNATION │ │
│ │ │ │ │
│ │ │ • JavaScript heap evicted │ │
│ │ │ • WebSocket connections maintained │ │
│ │ │ • Storage persisted │ │
│ │ │ • NO BILLING during hibernation │ │
│ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │
│ │ │ │
│ │ │ Incoming request or WebSocket message │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────────────────────────────┐ │
│ │ │ WAKE UP │ │
│ │ │ │ │
│ │ │ • Constructor called again │ │
│ │ │ • WebSockets restored via getWebSockets() │ │
│ │ │ • Attachments deserialized │ │
│ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ └──▶ Active (processing requests) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
R2 Object Storage
Architecture
R2 is Cloudflare's S3-compatible object storage, designed for egress-free access from Workers.
┌─────────────────────────────────────────────────────────────────────────────┐
│ R2 ARCHITECTURE │
│ │
│ Worker Request ────────────────────────────────────────────────────────── │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ R2 BINDING │ │
│ │ │ │
│ │ const object = await env.BUCKET.get('path/to/file.jpg'); │ │
│ │ │ │
│ │ // Direct binding - no HTTP overhead │ │
│ │ // Same Cloudflare network - minimal latency │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ R2 STORAGE LAYER │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ METADATA TIER │ │ │
│ │ │ │ │ │
│ │ │ • Object keys, sizes, ETags, custom metadata │ │ │
│ │ │ • Indexed for fast list() operations │ │ │
│ │ │ • Distributed across regions │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ DATA TIER │ │ │
│ │ │ │ │ │
│ │ │ • Actual object bytes │ │ │
│ │ │ • Erasure coded for durability (11 9s) │ │ │
│ │ │ • Distributed storage, automatic rebalancing │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ S3 Compatibility: │
│ • Full S3 API support via HTTPS endpoint │
│ • Existing S3 SDKs work out of the box │
│ • Migration from S3 via super-copy │
│ │
│ Pricing Advantage: │
│ • $0 egress to Workers (same network) │
│ • $0 egress to internet (vs $0.09/GB on S3) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
R2 Usage Patterns
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1); // Remove leading /
switch (request.method) {
case 'GET': {
const object = await env.BUCKET.get(key);
if (!object) {
return new Response('Not Found', { status: 404 });
}
// Stream directly to response - efficient for large files
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.etag,
'Cache-Control': 'public, max-age=31536000, immutable'
}
});
}
case 'PUT': {
// Multipart upload for large files
if (request.headers.get('content-length') > 100 * 1024 * 1024) {
return handleMultipartUpload(request, env, key);
}
await env.BUCKET.put(key, request.body, {
httpMetadata: {
contentType: request.headers.get('content-type') || 'application/octet-stream'
},
customMetadata: {
uploadedBy: request.headers.get('x-user-id') || 'anonymous',
uploadedAt: new Date().toISOString()
}
});
return new Response('Created', { status: 201 });
}
case 'DELETE': {
await env.BUCKET.delete(key);
return new Response('Deleted', { status: 200 });
}
default:
return new Response('Method Not Allowed', { status: 405 });
}
}
};
async function handleMultipartUpload(
request: Request,
env: Env,
key: string
): Promise<Response> {
// Start multipart upload
const upload = await env.BUCKET.createMultipartUpload(key);
const reader = request.body?.getReader();
if (!reader) {
return new Response('No body', { status: 400 });
}
const parts: R2UploadedPart[] = [];
let partNumber = 1;
const PART_SIZE = 10 * 1024 * 1024; // 10MB parts
let buffer = new Uint8Array(0);
while (true) {
const { done, value } = await reader.read();
if (value) {
// Accumulate data
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
// Upload when we have enough
while (buffer.length >= PART_SIZE) {
const part = await upload.uploadPart(
partNumber,
buffer.slice(0, PART_SIZE)
);
parts.push(part);
buffer = buffer.slice(PART_SIZE);
partNumber++;
}
}
if (done) {
// Upload remaining data
if (buffer.length > 0) {
const part = await upload.uploadPart(partNumber, buffer);
parts.push(part);
}
break;
}
}
// Complete multipart upload
await upload.complete(parts);
return new Response('Created', { status: 201 });
}
Queues Architecture
Message Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLOUDFLARE QUEUES │
│ │
│ Producer Worker │
│ │ │
│ │ await env.MY_QUEUE.send({ data: "..." }); │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ QUEUE │ │
│ │ │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │ M1 │ │ M2 │ │ M3 │ │ M4 │ │ M5 │ │ M6 │ │ M7 │ │ │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ │
│ │ │ │
│ │ • At-least-once delivery │ │
│ │ • Automatic batching │ │
│ │ • Retry with backoff │ │
│ │ • Dead letter queue support │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Batch delivered to consumer │
│ │ │
│ ▼ │
│ Consumer Worker │
│ │
│ export default { │
│ async queue(batch: MessageBatch, env: Env): Promise<void> { │
│ for (const message of batch.messages) { │
│ await processMessage(message.body); │
│ message.ack(); // Acknowledge individual message │
│ } │
│ } │
│ }; │
│ │
│ Batching Configuration: │
│ • max_batch_size: 100 (messages per batch) │
│ • max_batch_timeout: 5s (max wait for full batch) │
│ • max_retries: 3 (before dead letter) │
│ • retry_delay: exponential backoff │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Queue Patterns
// Producer: Fan-out pattern
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const order = await request.json();
// Send to multiple queues for parallel processing
await Promise.all([
env.INVENTORY_QUEUE.send({
type: 'reserve',
orderId: order.id,
items: order.items
}),
env.PAYMENT_QUEUE.send({
type: 'charge',
orderId: order.id,
amount: order.total
}),
env.NOTIFICATION_QUEUE.send({
type: 'order_created',
orderId: order.id,
email: order.email
})
]);
return new Response(JSON.stringify({ orderId: order.id }));
}
};
// Consumer: Batch processing with error handling
export default {
async queue(batch: MessageBatch<QueueMessage>, env: Env): Promise<void> {
const results = await Promise.allSettled(
batch.messages.map(async (message) => {
try {
await processMessage(message.body, env);
message.ack();
} catch (error) {
// Retry or dead-letter based on attempt count
if (message.attempts >= 3) {
// Move to dead letter queue
await env.DLQ.send({
original: message.body,
error: error.message,
attempts: message.attempts
});
message.ack(); // Ack to prevent further retries
} else {
message.retry({
delaySeconds: Math.pow(2, message.attempts) * 10 // Exponential backoff
});
}
}
})
);
// Log batch results
const succeeded = results.filter(r => r.status === 'fulfilled').length;
console.log(`Processed ${succeeded}/${batch.messages.length} messages`);
}
};
Security Architecture
Isolate Security Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ SECURITY BOUNDARIES │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PROCESS BOUNDARY │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ V8 ISOLATE BOUNDARY │ │ │
│ │ │ │ │ │
│ │ │ Tenant A Code │ │ │
│ │ │ ├── Cannot access other isolates' memory │ │ │
│ │ │ ├── Cannot access filesystem │ │ │
│ │ │ ├── Cannot open raw sockets │ │ │
│ │ │ ├── Cannot execute arbitrary binaries │ │ │
│ │ │ └── Cannot access process environment │ │ │
│ │ │ │ │ │
│ │ │ Available APIs (allowlist): │ │ │
│ │ │ ├── fetch() - HTTP client with restrictions │ │ │
│ │ │ ├── crypto - WebCrypto API │ │ │
│ │ │ ├── TextEncoder/TextDecoder │ │ │
│ │ │ ├── URL/URLSearchParams │ │ │
│ │ │ ├── Bindings (KV, DO, R2, Queues) │ │ │
│ │ │ └── Standard JavaScript built-ins │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Other Isolates (same process, isolated memory) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Known Limitations: │
│ • Timing side-channels (Spectre mitigations in place but not perfect) │
│ • Shared CPU (noisy neighbor possible) │
│ • No seccomp-level syscall filtering (V8 sandbox instead) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Secrets Management
// wrangler.toml - secrets are NOT stored here
// Instead, use: wrangler secret put SECRET_NAME
// Secrets are injected as environment bindings
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// env.API_KEY is the secret value
// Never logged, never exposed in stack traces
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Bearer ${env.API_KEY}`
}
});
return response;
}
};
// Secret rotation pattern
// Store current + previous secrets for zero-downtime rotation
interface Env {
API_KEY_CURRENT: string;
API_KEY_PREVIOUS: string;
}
async function authenticatedFetch(url: string, env: Env): Promise<Response> {
// Try current key first
let response = await fetch(url, {
headers: { 'Authorization': `Bearer ${env.API_KEY_CURRENT}` }
});
// Fallback to previous key if current fails
if (response.status === 401 && env.API_KEY_PREVIOUS) {
response = await fetch(url, {
headers: { 'Authorization': `Bearer ${env.API_KEY_PREVIOUS}` }
});
}
return response;
}
Debugging and Observability
Logging Architecture
// Workers logs are available via:
// 1. wrangler tail (real-time streaming)
// 2. Logpush (to R2, S3, or HTTP endpoint)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const requestId = crypto.randomUUID();
const startTime = Date.now();
// Structured logging
console.log(JSON.stringify({
level: 'info',
requestId,
event: 'request_start',
method: request.method,
url: request.url,
cf: {
colo: request.cf?.colo,
country: request.cf?.country,
asn: request.cf?.asn
}
}));
try {
const response = await handleRequest(request, env);
console.log(JSON.stringify({
level: 'info',
requestId,
event: 'request_complete',
status: response.status,
durationMs: Date.now() - startTime
}));
return response;
} catch (error) {
console.log(JSON.stringify({
level: 'error',
requestId,
event: 'request_error',
error: error.message,
stack: error.stack,
durationMs: Date.now() - startTime
}));
return new Response('Internal Error', { status: 500 });
}
}
};
Tracing Integration
// OpenTelemetry-compatible tracing
import { trace } from '@opentelemetry/api';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const tracer = trace.getTracer('my-worker');
return tracer.startActiveSpan('handle-request', async (span) => {
try {
span.setAttribute('http.method', request.method);
span.setAttribute('http.url', request.url);
span.setAttribute('cf.colo', request.cf?.colo || 'unknown');
// Child span for database operation
const data = await tracer.startActiveSpan('kv-get', async (kvSpan) => {
const result = await env.KV.get('key');
kvSpan.setAttribute('kv.hit', result !== null);
kvSpan.end();
return result;
});
// Child span for external API
const apiResult = await tracer.startActiveSpan('api-call', async (apiSpan) => {
const response = await fetch('https://api.example.com/data');
apiSpan.setAttribute('http.status_code', response.status);
apiSpan.end();
return response.json();
});
const response = new Response(JSON.stringify(apiResult));
span.setAttribute('http.status_code', response.status);
span.end();
return response;
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
span.end();
throw error;
}
});
}
};
Production Patterns
Graceful Degradation
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const cacheKey = new Request(request.url, { method: 'GET' });
// Try cache first
const cached = await caches.default.match(cacheKey);
if (cached) {
return cached;
}
// Try origin with fallbacks
try {
const response = await fetchWithRetry(env.ORIGIN + new URL(request.url).pathname, {
retries: 2,
timeout: 5000
});
// Cache successful response
const clone = response.clone();
await caches.default.put(cacheKey, clone);
return response;
} catch (originError) {
// Origin failed - try stale cache
const stale = await env.STALE_CACHE_KV.get(request.url);
if (stale) {
console.log('Serving stale content due to origin failure');
return new Response(stale, {
headers: {
'Content-Type': 'application/json',
'X-Cache-Status': 'stale-origin-error'
}
});
}
// Nothing cached - return error page
return new Response(
JSON.stringify({ error: 'Service temporarily unavailable' }),
{
status: 503,
headers: {
'Content-Type': 'application/json',
'Retry-After': '30'
}
}
);
}
}
};
async function fetchWithRetry(
url: string,
options: { retries: number; timeout: number }
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= options.retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.ok) {
return response;
}
if (response.status >= 500) {
lastError = new Error(`Origin returned ${response.status}`);
continue; // Retry on 5xx
}
return response; // Return 4xx without retry
} catch (error) {
lastError = error;
// Exponential backoff
if (attempt < options.retries) {
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 100));
}
}
}
throw lastError;
}
A/B Testing at the Edge
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Get or assign variant
const cookies = parseCookies(request.headers.get('Cookie') || '');
let variant = cookies['ab-variant'];
if (!variant) {
// Assign based on consistent hash of user identifier
const userId = cookies['user-id'] || request.headers.get('CF-Connecting-IP');
variant = hashToVariant(userId, ['control', 'treatment-a', 'treatment-b']);
}
// Fetch variant-specific content
const response = await fetch(`${env.ORIGIN}?variant=${variant}`, {
headers: request.headers
});
// Clone response to modify headers
const newResponse = new Response(response.body, response);
// Set variant cookie for consistency
newResponse.headers.append(
'Set-Cookie',
`ab-variant=${variant}; Path=/; Max-Age=86400; SameSite=Lax`
);
// Track assignment for analytics
env.ctx.waitUntil(
env.ANALYTICS_QUEUE.send({
event: 'ab_assignment',
variant,
url: request.url,
timestamp: Date.now()
})
);
return newResponse;
}
};
function hashToVariant(input: string, variants: string[]): string {
// Consistent hash for deterministic assignment
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash = hash & hash;
}
return variants[Math.abs(hash) % variants.length];
}
Tradeoffs and Limitations
What Workers Does Well
| Strength | Why |
|---|---|
| Low latency | Runs at the edge, close to users |
| Fast cold starts | V8 isolates, not containers |
| Global distribution | 300+ PoPs, automatic |
| Stateless compute | Simple scaling model |
| WebSocket support | With Durable Objects |
| Cost efficiency | Pay per request, not per instance |
What Workers Struggles With
| Limitation | Workaround |
|---|---|
| CPU-intensive work | Offload to origin or use paid limits |
| Long-running processes | Use Queues for async processing |
| Large memory workloads | Stream data, don't buffer |
| Complex state | Durable Objects (but with latency cost) |
| Legacy Node.js APIs | Use polyfills or adapt code |
| Debugging | Limited to logs, no debugger attachment |
Decision Framework
Should this run on Workers?
┌─────────────────────────────────────────────────────────────────┐
│ REQUEST CLASSIFICATION │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Is latency the primary concern? │
└─────────────────────────────────────────────────────────────────┘
Yes / No
┌─────┴─────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ Consider │ │ Consider │
│ Workers │ │ Origin │
└─────┬─────┘ └───────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Does it need >30ms CPU time? │
└─────────────────────────────────────────────────────────────────┘
Yes / No
┌─────┴─────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ Origin │ │ Continue │
└───────────┘ └─────┬─────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Does it need strong consistency across regions? │
└─────────────────────────────────────────────────────────────────┘
Yes / No
┌─────┴─────┐
▼ ▼
┌─────────────────┐ ┌───────────┐
│ Durable Objects │ │ Workers │
│ (accept latency │ │ + KV │
│ to single loc) │ │ │
└─────────────────┘ └───────────┘
Summary
Cloudflare Workers represents a fundamental shift in how we think about edge compute. The V8 isolate model enables unprecedented density and cold-start performance, but comes with real constraints that shape what's possible.
Key Architectural Insights:
-
Isolates enable density - 10,000+ isolates per server vs 10-50 containers. Cold starts in milliseconds, not seconds.
-
CPU time ≠ wall time - You're billed for compute, not waiting. Design for async I/O.
-
KV is read-optimized - ~60s propagation delay. Use Durable Objects when you need consistency.
-
Durable Objects serialize access - Strong consistency through single-threading, but latency cost for cross-region access.
-
waitUntil() is essential - Don't block responses on logging, analytics, or cache updates.
-
Cold starts compound - Minimize top-level work, lazy-load dependencies, cache initialization in KV.
-
Security is V8-level - No filesystem, no raw sockets, controlled APIs. Trust the sandbox.
Workers is not a replacement for traditional backends—it's a layer that handles what benefits from edge execution: routing, auth, personalization, caching, and latency-sensitive transformations. Understanding where it excels and where it doesn't is the difference between a successful edge architecture and fighting the platform.
What did you think?