The conventional serverless model sends your code to a handful of regions and hopes users are nearby. Cloudflare Workers flips this: your code runs in 300+ cities worldwide, executing within milliseconds of every user on the planet. There are no cold starts in the traditional sense — Workers use V8 isolates instead of containers, spinning up in under a millisecond. And the pricing model is generous: 100,000 requests per day on the free tier, with paid plans at $0.30 per million requests.
Workers aren’t just a function-as-a-service platform. They’re the runtime layer for a full ecosystem: KV for globally replicated key-value storage, R2 for S3-compatible object storage with zero egress fees, D1 for SQLite at the edge, Durable Objects for globally consistent stateful coordination, Queues for reliable message passing, and Pages for full-stack web application deployment.
This guide covers the complete Workers platform — from your first Hello World to production patterns for authentication, caching, database access, and real-time coordination.
How Workers Are Different
V8 Isolates, Not Containers
Traditional serverless (Lambda, Cloud Functions) boots a container per invocation. Container cold starts take hundreds of milliseconds to seconds. Workers use V8 isolates — lightweight JavaScript execution contexts that share a single V8 instance per Worker process. Creating an isolate takes under a millisecond. There’s no OS boot, no container startup, no JVM initialization.
Lambda cold start:
Download package → Boot container → Start runtime → Run handler
~200ms - 3s
Workers "cold start":
Create V8 isolate → Run handler
<1ms
After first request, the isolate stays warm for subsequent requests
in the same data center.
The Runtime: Not Node.js
Workers run a subset of the Web Platform APIs — fetch, Request, Response, URL, Headers, crypto, TextEncoder, ReadableStream, WritableStream. This isn’t Node.js; there’s no fs, no require, no process. Workers support:
- JavaScript (ES2022+)
- TypeScript (compiled by Wrangler)
- Python (Workers Python runtime)
- Rust (compiled to WASM)
- Any language that compiles to WASM
The Request/Response Model
Every Worker is fundamentally an HTTP handler:
1
2
3
4
5
6
7
|
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return new Response("Hello, World!", {
headers: { "Content-Type": "text/plain" },
});
},
};
|
request — the incoming HTTP request (URL, method, headers, body)
env — bindings to KV, R2, D1, Durable Objects, secrets, and variables
ctx — execution context (for waitUntil background tasks)
Getting Started with Wrangler
Wrangler is the Cloudflare CLI for Workers development, testing, and deployment.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
npm install -g wrangler
# Authenticate
wrangler login
# Create a new project
wrangler init my-worker --type typescript
cd my-worker
# Project structure:
# src/index.ts — your Worker code
# wrangler.toml — configuration
# package.json
# tsconfig.json
# Run locally (uses Miniflare — a local Workers runtime)
wrangler dev
# Deploy to Cloudflare's network
wrangler deploy
|
wrangler.toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23" # Pin runtime APIs to a date
# Environment variables (not secrets)
[vars]
ENVIRONMENT = "production"
API_VERSION = "v2"
# KV namespace binding
[[kv_namespaces]]
binding = "CACHE"
id = "abc123def456..."
# R2 bucket binding
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "my-app-assets"
# D1 database binding
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Service binding (call another Worker)
[[services]]
binding = "AUTH_WORKER"
service = "auth-service"
# Durable Object binding
[[durable_objects.bindings]]
name = "ROOMS"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]
# Per-environment overrides
[env.staging]
vars = { ENVIRONMENT = "staging" }
[env.staging.vars]
API_BASE_URL = "https://staging-api.myapp.com"
|
Core Worker Patterns
Routing
Workers intercept all requests to your zone. Route them like a mini web framework:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
|
// src/index.ts
import { Router } from "itty-router"; // npm install itty-router
interface Env {
DB: D1Database;
CACHE: KVNamespace;
ASSETS: R2Bucket;
AUTH_TOKEN: string; // Secret
}
const router = Router<Request, [Env, ExecutionContext]>();
// Middleware: authentication
async function authenticate(request: Request, env: Env): Promise<Response | void> {
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token || token !== env.AUTH_TOKEN) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
}
router
.get("/api/health", () => new Response("OK"))
.get("/api/users/:id", authenticate, handleGetUser)
.post("/api/users", authenticate, handleCreateUser)
.get("/assets/*", handleAsset)
.all("*", () => new Response("Not Found", { status: 404 }));
async function handleGetUser(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const url = new URL(request.url);
const userId = url.pathname.split("/").pop()!;
// Try cache first
const cached = await env.CACHE.get(`user:${userId}`, "json");
if (cached) {
return new Response(JSON.stringify(cached), {
headers: {
"Content-Type": "application/json",
"X-Cache": "HIT",
},
});
}
// Query D1
const user = await env.DB.prepare(
"SELECT id, name, email, created_at FROM users WHERE id = ?",
)
.bind(userId)
.first();
if (!user) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// Cache for 5 minutes in the background (don't block the response)
ctx.waitUntil(
env.CACHE.put(`user:${userId}`, JSON.stringify(user), { expirationTtl: 300 }),
);
return new Response(JSON.stringify(user), {
headers: {
"Content-Type": "application/json",
"X-Cache": "MISS",
},
});
}
export default {
fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
router.handle(request, env, ctx),
};
|
Workers excel at sitting in front of your origin and transforming requests:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Route /api/* to your origin, transform headers
if (url.pathname.startsWith("/api/")) {
return proxyToOrigin(request, env);
}
// Serve static assets from R2
if (url.pathname.startsWith("/static/")) {
return serveFromR2(request, env);
}
// Everything else: add security headers and proxy
return addSecurityHeaders(await fetch(request));
},
};
async function proxyToOrigin(request: Request, env: Env): Promise<Response> {
const originUrl = new URL(request.url);
originUrl.hostname = "api.myapp.internal";
const modifiedRequest = new Request(originUrl.toString(), {
method: request.method,
headers: {
...Object.fromEntries(request.headers),
// Add internal auth header, remove public Authorization
"X-Internal-Token": env.INTERNAL_TOKEN,
"X-Forwarded-For": request.headers.get("CF-Connecting-IP") ?? "",
"X-Country": request.cf?.country ?? "unknown",
},
body: request.method !== "GET" && request.method !== "HEAD"
? request.body
: undefined,
});
const response = await fetch(modifiedRequest);
// Strip internal headers before returning to client
const cleanedHeaders = new Headers(response.headers);
cleanedHeaders.delete("X-Internal-Error-Details");
cleanedHeaders.set("X-Powered-By", "Cloudflare Workers");
return new Response(response.body, {
status: response.status,
headers: cleanedHeaders,
});
}
function addSecurityHeaders(response: Response): Response {
const headers = new Headers(response.headers);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Frame-Options", "DENY");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
);
headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
);
return new Response(response.body, { status: response.status, headers });
}
|
Geolocation and Smart Routing
Every request to a Worker carries rich Cloudflare metadata in request.cf:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
interface CloudflareRequestProperties {
country: string; // "US", "DE", "JP"
continent: string; // "NA", "EU", "AS"
city: string; // "San Francisco"
region: string; // "California"
regionCode: string; // "CA"
latitude: string; // "37.7595"
longitude: string; // "-122.4367"
timezone: string; // "America/Los_Angeles"
asn: number; // Autonomous System Number
asOrganization: string; // ISP/org name
colo: string; // Cloudflare data center (e.g. "SFO")
botManagement?: {
score: number; // 0-99, lower = more likely bot
verifiedBot: boolean;
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const cf = request.cf as CloudflareRequestProperties;
// Route to the nearest database replica
const dbRegion = getClosestRegion(cf.continent, cf.country);
// Block traffic from high-risk countries (example — adjust to your policy)
if (env.BLOCKED_COUNTRIES?.includes(cf.country)) {
return new Response("Service not available in your region", {
status: 451,
});
}
// Block bots (except verified bots like Googlebot)
if (cf.botManagement && cf.botManagement.score < 30 && !cf.botManagement.verifiedBot) {
return new Response("Forbidden", { status: 403 });
}
// Serve localized content
const locale = countryToLocale(cf.country) ?? "en-US";
const response = await fetch(request);
const headers = new Headers(response.headers);
headers.set("X-User-Locale", locale);
headers.set("X-User-Country", cf.country);
return new Response(response.body, { status: response.status, headers });
},
};
function getClosestRegion(continent: string, country: string): string {
const regionMap: Record<string, string> = {
NA: "us-east-1",
EU: "eu-west-1",
AS: "ap-southeast-1",
OC: "ap-southeast-2",
};
return regionMap[continent] ?? "us-east-1";
}
|
KV: Global Key-Value Storage
Workers KV is eventually consistent, globally replicated key-value storage. Writes propagate to all edge locations within ~60 seconds. It’s designed for read-heavy workloads — configuration, feature flags, user sessions, cached API responses.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// KV operations
const env: Env; // has CACHE: KVNamespace
// Write
await env.CACHE.put("config:feature-flags", JSON.stringify({
newCheckout: true,
darkMode: false,
}), {
expirationTtl: 3600, // Expire after 1 hour
// Or: expiration: Math.floor(Date.now() / 1000) + 3600 // Unix timestamp
});
// Read
const flags = await env.CACHE.get("config:feature-flags", "json");
// Returns null if key doesn't exist or has expired
// Read with metadata
const { value, metadata } = await env.CACHE.getWithMetadata<FeatureFlags, { version: number }>(
"config:feature-flags",
"json"
);
// Write with metadata
await env.CACHE.put("config:feature-flags", JSON.stringify(flags), {
metadata: { version: 42, updatedBy: "admin" },
expirationTtl: 3600,
});
// Delete
await env.CACHE.delete("config:feature-flags");
// List keys with a prefix
const { keys, list_complete, cursor } = await env.CACHE.list({
prefix: "session:",
limit: 100,
});
// keys = [{ name: "session:abc123", expiration: 1234567890 }, ...]
// Paginate with cursor if !list_complete
// KV is great for: feature flags, rate limiting counters, session storage,
// configuration that changes infrequently, A/B test assignments
|
Feature Flags with KV
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
async function isFeatureEnabled(
env: Env,
feature: string,
userId: string,
): Promise<boolean> {
const flags = await env.CACHE.get<Record<string, FeatureFlag>>(
"feature-flags",
"json",
);
if (!flags || !flags[feature]) return false;
const flag = flags[feature];
if (!flag.enabled) return false;
// Percentage rollout: hash userId to 0-99, enable if below threshold
if (flag.rolloutPercentage < 100) {
const hash = await hashUserId(userId);
return hash % 100 < flag.rolloutPercentage;
}
return true;
}
async function hashUserId(userId: string): Promise<number> {
const data = new TextEncoder().encode(userId);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = new Uint8Array(hashBuffer);
return hashArray[0]; // 0-255, mod 100 gives 0-99
}
|
R2: Object Storage with Zero Egress Fees
R2 is Cloudflare’s S3-compatible object storage. The killer feature: no egress fees. Serving 10TB/month from S3 costs ~$920 in egress alone; from R2 it’s $0.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
// Upload a file to R2
async function handleUpload(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1); // Strip leading /
// Validate content type
const contentType = request.headers.get("Content-Type") ?? "application/octet-stream";
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
if (!allowedTypes.includes(contentType)) {
return new Response("Unsupported media type", { status: 415 });
}
// Upload directly from the request stream — no buffering
await env.ASSETS.put(key, request.body, {
httpMetadata: {
contentType,
cacheControl: "public, max-age=31536000, immutable",
},
customMetadata: {
uploadedBy: request.headers.get("X-User-ID") ?? "anonymous",
uploadedAt: new Date().toISOString(),
},
});
return new Response(JSON.stringify({ key, url: `https://assets.myapp.com/${key}` }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
// Serve a file from R2 with range request support (for video streaming)
async function handleDownload(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(8); // Strip /assets/
const object = await env.ASSETS.get(key, {
range: request.headers.get("Range")
? { ...parseRange(request.headers.get("Range")!) }
: undefined,
onlyIf: {
etagMatches: request.headers.get("If-None-Match") ?? undefined,
uploadedAfter: request.headers.get("If-Modified-Since")
? new Date(request.headers.get("If-Modified-Since")!)
: undefined,
},
});
if (!object) {
return new Response("Not Found", { status: 404 });
}
// 304 Not Modified
if (object.body === null) {
return new Response(null, { status: 304 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("ETag", object.httpEtag);
headers.set("Accept-Ranges", "bytes");
if (object.range) {
headers.set(
"Content-Range",
`bytes ${object.range.offset}-${object.range.end}/${object.size}`,
);
return new Response(object.body, { status: 206, headers });
}
return new Response(object.body, { headers });
}
// Generate a presigned URL for direct client uploads
async function createPresignedUploadUrl(
env: Env,
key: string,
): Promise<string> {
// R2 supports presigned URLs via the S3-compatible API
// Use the AWS SDK pointed at R2's S3 endpoint
const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner");
const client = new S3Client({
region: "auto",
endpoint: `https://${env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});
return getSignedUrl(
client,
new PutObjectCommand({ Bucket: "my-app-assets", Key: key }),
{ expiresIn: 3600 },
);
}
|
D1: SQLite at the Edge
D1 is Cloudflare’s managed SQLite database. Each D1 database runs in a primary location with read replicas automatically placed near your Workers. It’s not a distributed database — it’s SQLite with global read access and single-writer consistency.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
// D1 query patterns
async function getUser(env: Env, userId: string) {
// Single row
const user = await env.DB.prepare(
"SELECT id, name, email, plan FROM users WHERE id = ? AND deleted_at IS NULL",
)
.bind(userId)
.first<User>();
return user; // null if not found
}
async function listUserPosts(env: Env, userId: string, page = 1, perPage = 20) {
const offset = (page - 1) * perPage;
// Multiple rows
const { results } = await env.DB.prepare(
`SELECT p.id, p.title, p.slug, p.published_at, p.view_count
FROM posts p
WHERE p.author_id = ? AND p.published_at IS NOT NULL
ORDER BY p.published_at DESC
LIMIT ? OFFSET ?`,
)
.bind(userId, perPage, offset)
.all<Post>();
return results;
}
async function createUser(
env: Env,
name: string,
email: string,
): Promise<User> {
const id = crypto.randomUUID();
const result = await env.DB.prepare(
"INSERT INTO users (id, name, email, created_at) VALUES (?, ?, ?, ?) RETURNING *",
)
.bind(id, name, email, new Date().toISOString())
.first<User>();
if (!result) throw new Error("Failed to create user");
return result;
}
// Batch multiple statements in one round-trip
async function transferCredits(
env: Env,
fromUserId: string,
toUserId: string,
amount: number,
): Promise<void> {
// D1 batch runs all statements atomically
await env.DB.batch([
env.DB.prepare(
"UPDATE users SET credits = credits - ? WHERE id = ? AND credits >= ?",
).bind(amount, fromUserId, amount),
env.DB.prepare(
"UPDATE users SET credits = credits + ? WHERE id = ?",
).bind(amount, toUserId),
env.DB.prepare(
"INSERT INTO credit_transfers (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)",
).bind(fromUserId, toUserId, amount, new Date().toISOString()),
]);
}
// D1 Migrations with Wrangler
// Create migration files: wrangler d1 migrations create my-app-db add_users_table
// Apply: wrangler d1 migrations apply my-app-db
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
-- migrations/0001_create_users.sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
credits INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_plan ON users(plan);
-- migrations/0002_create_posts.sql
CREATE TABLE posts (
id TEXT PRIMARY KEY,
author_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
published_at TEXT,
view_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_posts_published ON posts(published_at) WHERE published_at IS NOT NULL;
|
Durable Objects: Stateful Coordination at the Edge
Durable Objects are the most powerful and unique part of the Workers platform. Each Durable Object is a single-threaded actor with its own persistent storage. Requests to the same Durable Object always route to the same instance in the same location. This gives you strong consistency — something impossible with KV’s eventual consistency.
Use cases: real-time collaboration, rate limiters that are truly atomic, WebSocket connection management, distributed locks, game servers.
Rate Limiter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
// src/rate-limiter.ts
export class RateLimiter implements DurableObject {
private state: DurableObjectState;
private storage: DurableObjectStorage;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.storage = state.storage;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const action = url.pathname.slice(1); // "check" or "reset"
if (action === "reset") {
await this.storage.deleteAll();
return new Response("OK");
}
// action === "check"
const limit = parseInt(url.searchParams.get("limit") ?? "100");
const windowSeconds = parseInt(url.searchParams.get("window") ?? "60");
return this.state.blockConcurrencyWhile(async () => {
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
// Get current window data
let count = (await this.storage.get<number>("count")) ?? 0;
let windowEnd = (await this.storage.get<number>("window_end")) ?? 0;
// Reset if window has expired
if (now > windowEnd) {
count = 0;
windowEnd = now + windowSeconds * 1000;
}
// Increment and check
count++;
const allowed = count <= limit;
const remaining = Math.max(0, limit - count);
const reset = Math.ceil(windowEnd / 1000);
// Persist the new count
await this.storage.put("count", count);
await this.storage.put("window_end", windowEnd);
return new Response(
JSON.stringify({ allowed, remaining, reset, limit }),
{
status: allowed ? 200 : 429,
headers: {
"Content-Type": "application/json",
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"X-RateLimit-Reset": String(reset),
},
},
);
});
}
}
// Using the rate limiter from a Worker
async function checkRateLimit(
env: Env,
identifier: string, // IP address, user ID, API key
limit = 100,
windowSeconds = 60,
): Promise<{ allowed: boolean; remaining: number; reset: number }> {
const id = env.RATE_LIMITER.idFromName(identifier);
const obj = env.RATE_LIMITER.get(id);
const response = await obj.fetch(
`http://internal/check?limit=${limit}&window=${windowSeconds}`,
);
return response.json();
}
|
WebSocket Chat Room
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
|
// src/chat-room.ts
interface Message {
type: "message" | "join" | "leave";
user: string;
content?: string;
timestamp: number;
}
export class ChatRoom implements DurableObject {
private state: DurableObjectState;
private sessions: Map<string, WebSocket> = new Map();
private usernames: Map<WebSocket, string> = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
// Restore WebSocket connections after hibernation
this.state.getWebSockets().forEach((ws) => {
const username = ws.deserializeAttachment();
this.sessions.set(username, ws);
this.usernames.set(ws, username);
});
}
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get("Upgrade");
if (upgradeHeader !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const url = new URL(request.url);
const username = url.searchParams.get("username") ?? `User-${Math.random().toString(36).slice(2, 7)}`;
// Upgrade to WebSocket
const { 0: client, 1: server } = new WebSocketPair();
// Accept using Hibernatable WebSockets API (keeps Durable Object alive without active connections)
this.state.acceptWebSocket(server);
server.serializeAttachment(username);
this.sessions.set(username, server);
this.usernames.set(server, username);
// Broadcast join event
this.broadcast({
type: "join",
user: username,
timestamp: Date.now(),
}, server);
// Send recent message history
const history = await this.state.storage.get<Message[]>("history") ?? [];
server.send(JSON.stringify({ type: "history", messages: history.slice(-50) }));
return new Response(null, { status: 101, webSocket: client });
}
// Called by the runtime when a WebSocket message is received
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const username = this.usernames.get(ws);
if (!username) return;
let data: { content: string };
try {
data = JSON.parse(message as string);
} catch {
return;
}
const msg: Message = {
type: "message",
user: username,
content: data.content.slice(0, 500), // Limit message length
timestamp: Date.now(),
};
// Persist to history (keep last 100 messages)
const history = await this.state.storage.get<Message[]>("history") ?? [];
history.push(msg);
if (history.length > 100) history.shift();
await this.state.storage.put("history", history);
// Broadcast to all connected clients
this.broadcast(msg);
}
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
const username = this.usernames.get(ws);
if (!username) return;
this.sessions.delete(username);
this.usernames.delete(ws);
this.broadcast({ type: "leave", user: username, timestamp: Date.now() });
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
ws.close(1011, "Internal error");
}
private broadcast(message: Message, exclude?: WebSocket): void {
const payload = JSON.stringify(message);
for (const [, ws] of this.sessions) {
if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
try {
ws.send(payload);
} catch {
// Connection closed, will be cleaned up in webSocketClose
}
}
}
}
}
// Client-side Worker: routes WebSocket upgrades to the right room
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/room/")) {
const roomName = url.pathname.slice(6); // Strip /room/
if (!roomName) return new Response("Room name required", { status: 400 });
// Get or create the Durable Object for this room
// All connections to the same room name go to the same DO instance
const id = env.ROOMS.idFromName(roomName);
const room = env.ROOMS.get(id);
return room.fetch(request);
}
return new Response("Not Found", { status: 404 });
},
};
|
Queues: Reliable Background Processing
Workers Queues provide reliable message delivery between Workers. The producer Worker sends a message; the consumer Worker processes it with automatic retries and dead-letter queuing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
// wrangler.toml
// [[queues.producers]]
// binding = "MY_QUEUE"
// queue = "my-queue"
//
// [[queues.consumers]]
// queue = "my-queue"
// max_batch_size = 10
// max_batch_timeout = 5
// max_retries = 3
// dead_letter_queue = "my-queue-dlq"
// Producer: send a message
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const body = await request.json<{ email: string; template: string }>();
await env.MY_QUEUE.send({
type: "send-email",
to: body.email,
template: body.template,
timestamp: Date.now(),
});
return new Response(JSON.stringify({ queued: true }), {
headers: { "Content-Type": "application/json" },
});
},
};
// Consumer: process messages from the queue
export default {
async queue(batch: MessageBatch<EmailJob>, env: Env): Promise<void> {
for (const message of batch.messages) {
try {
await sendEmail(env, message.body);
message.ack(); // Remove from queue
} catch (error) {
console.error("Failed to send email:", error);
message.retry({ delaySeconds: 60 }); // Retry after 1 minute
}
}
},
};
// Batch producer: send multiple messages efficiently
async function queueBulkEmails(env: Env, emails: EmailJob[]): Promise<void> {
await env.MY_QUEUE.sendBatch(
emails.map((job) => ({
body: job,
delaySeconds: 0,
})),
);
}
|
Cloudflare Pages: Full-Stack Apps
Pages deploys full-stack apps with Workers Functions as the backend:
my-app/
├── public/ # Static assets
│ ├── index.html
│ └── assets/
├── functions/ # Workers Functions (server-side)
│ ├── _middleware.ts # Runs on all routes
│ ├── api/
│ │ ├── users.ts # Handles /api/users
│ │ └── posts/
│ │ └── [id].ts # Handles /api/posts/:id (dynamic route)
└── package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// functions/api/users.ts
import type { PagesFunction } from "@cloudflare/workers-types";
interface Env {
DB: D1Database;
}
export const onRequestGet: PagesFunction<Env> = async ({ env, request }) => {
const users = await env.DB.prepare("SELECT id, name FROM users LIMIT 50").all();
return Response.json(users.results);
};
export const onRequestPost: PagesFunction<Env> = async ({ env, request }) => {
const { name, email } = await request.json<{ name: string; email: string }>();
const user = await env.DB.prepare(
"INSERT INTO users (id, name, email, created_at) VALUES (?, ?, ?, ?) RETURNING *",
)
.bind(crypto.randomUUID(), name, email, new Date().toISOString())
.first();
return Response.json(user, { status: 201 });
};
// functions/_middleware.ts — runs before all function routes
export const onRequest: PagesFunction<Env> = async ({ request, next }) => {
// CORS headers
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
const response = await next();
response.headers.set("Access-Control-Allow-Origin", "*");
return response;
};
|
1
2
3
4
5
|
# Deploy Pages project
wrangler pages deploy ./public --project-name my-app
# Or connect to a GitHub repo for automatic deployments on push
# Each PR gets its own preview URL automatically
|
Authentication Patterns
JWT Verification at the Edge
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
async function verifyJWT(token: string, secret: string): Promise<JWTPayload | null> {
const [headerB64, payloadB64, signatureB64] = token.split(".");
if (!headerB64 || !payloadB64 || !signatureB64) return null;
// Verify signature using Web Crypto API (available in Workers)
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify("HMAC", key, signature, data);
if (!valid) return null;
const payload = JSON.parse(atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")));
// Check expiry
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
return null; // Expired
}
return payload;
}
// Auth middleware
async function requireAuth(request: Request, env: Env): Promise<{ userId: string } | Response> {
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Missing token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const payload = await verifyJWT(token, env.JWT_SECRET);
if (!payload) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return { userId: payload.sub };
}
|
Cloudflare Access Integration
If your Workers are behind Cloudflare Access (SSO/zero-trust), validate the JWT automatically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// Workers behind Cloudflare Access receive a JWT in the CF-Access-JWT-Assertion header
async function validateAccessJWT(request: Request, env: Env): Promise<string | null> {
const token = request.headers.get("Cf-Access-Jwt-Assertion");
if (!token) return null;
// Fetch Access public keys (cache this in KV)
let certs = await env.CACHE.get<Record<string, string>>("access-certs", "json");
if (!certs) {
const resp = await fetch(`https://${env.CF_TEAM_DOMAIN}/cdn-cgi/access/certs`);
const data = await resp.json<{ public_certs: Array<{ kid: string; cert: string }> }>();
certs = Object.fromEntries(data.public_certs.map((c) => [c.kid, c.cert]));
await env.CACHE.put("access-certs", JSON.stringify(certs), { expirationTtl: 3600 });
}
// Verify and decode the JWT (use jose library or implement RS256 verification)
// The payload contains: email, sub, aud, iat, exp
const payload = await verifyAccessJWT(token, certs, env.CF_ACCESS_AUD);
return payload?.email ?? null;
}
|
Cache API: Granular Response Caching
The Cache API lets Workers cache responses at specific cache keys:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const cache = caches.default;
// Try to serve from cache
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Fetch from origin
const response = await fetch(request);
// Cache successful GET responses for 1 hour
if (request.method === "GET" && response.status === 200) {
const responseToCache = new Response(response.clone().body, response);
responseToCache.headers.set("Cache-Control", "public, max-age=3600");
ctx.waitUntil(cache.put(request, responseToCache));
}
return response;
},
};
// Custom cache key (e.g., vary by country)
async function fetchWithCountryCache(request: Request, country: string): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url + `?country=${country}`, request);
const cached = await cache.match(cacheKey);
if (cached) return cached;
const response = await fetch(request);
await cache.put(cacheKey, response.clone());
return response;
}
|
Streaming Responses
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
// Stream a large response without buffering the entire body in memory
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();
// Start writing in the background
(async () => {
writer.write(encoder.encode("data: start\n\n"));
for await (const chunk of generateLargeDataset(env)) {
writer.write(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
writer.write(encoder.encode("data: done\n\n"));
writer.close();
})();
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
},
};
|
Local Development and Testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
# Start local dev server (uses Miniflare under the hood)
wrangler dev
# Specify port and enable remote bindings (use your real KV/D1/R2)
wrangler dev --port 8787 --remote
# Run with local persistence (KV/D1 data persists between runs)
wrangler dev --persist
# Run tests (uses Miniflare's Vitest integration)
npm install -D @cloudflare/vitest-pool-workers vitest
# vitest.config.ts
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
});
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// src/index.test.ts
import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
import { describe, it, expect, beforeEach } from "vitest";
import worker from "./index";
describe("User API", () => {
beforeEach(async () => {
// Reset D1 between tests
await env.DB.exec("DELETE FROM users");
});
it("creates a user", async () => {
const ctx = createExecutionContext();
const request = new Request("http://localhost/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice", email: "alice@example.com" }),
});
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(201);
const user = await response.json<User>();
expect(user.name).toBe("Alice");
expect(user.email).toBe("alice@example.com");
});
it("returns 404 for missing user", async () => {
const request = new Request("http://localhost/api/users/nonexistent");
const response = await worker.fetch(request, env, createExecutionContext());
expect(response.status).toBe(404);
});
});
|
Limits and When Not to Use Workers
| Limit |
Value |
| CPU time per request |
30ms (free), 30s (paid) |
| Memory per request |
128MB |
| Script size (compressed) |
3MB |
| KV value size |
25MB |
| R2 object size |
5TB |
| D1 database size |
10GB |
| Durable Object storage |
10GB per object |
| WebSocket connections per DO |
32,768 |
Workers are not the right tool when you need:
- Long-running jobs — CPU-intensive work >30s, video transcoding, large ML inference. Use a traditional server or container.
- Shared filesystem access — Workers are stateless; use R2 or external storage.
- Full POSIX environment — no fork(), no native binaries, no arbitrary system calls.
- Very large in-memory state — 128MB limit per request; use Durable Objects or external stores.
- Relational workloads requiring joins across large tables — D1 is SQLite, not PostgreSQL; complex analytical queries belong in a data warehouse.
Putting It Together: Architecture Patterns
API Gateway Pattern
User → Cloudflare Workers (auth, rate limit, routing) → Origin servers
↓
KV (feature flags, session cache)
D1 (user data, lightweight queries)
R2 (file storage, CDN assets)
Durable Objects (rate limiting, real-time)
Queues (async email, webhooks)
Full-Stack Edge App
User → Cloudflare Pages (static assets served from 300+ PoPs)
↓ API calls
Pages Functions (Workers runtime)
↓
D1 (SQLite) + KV (cache) + R2 (assets)
Hybrid Architecture
Edge (Workers): auth, rate limiting, geo-routing, caching, A/B testing
Origin (k8s): heavy computation, complex queries, long-running jobs
Workers handles ~90% of requests from cache or edge logic.
Only ~10% reach the origin.
Deployment and Environments
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
# Deploy to production
wrangler deploy
# Deploy to a preview environment
wrangler deploy --env staging
# Roll back to a previous version
wrangler deployments list
wrangler rollback <deployment-id>
# Tail production logs in real-time
wrangler tail
# Set secrets (encrypted, never in wrangler.toml)
wrangler secret put JWT_SECRET
wrangler secret put DATABASE_URL
# Manage KV from the CLI
wrangler kv key put --binding CACHE "config:flags" '{"newUI":true}'
wrangler kv key get --binding CACHE "config:flags"
wrangler kv key list --binding CACHE --prefix "session:"
# Execute D1 queries
wrangler d1 execute my-app-db --command "SELECT COUNT(*) FROM users"
wrangler d1 execute my-app-db --file migrations/0001_create_users.sql
|
Filed under: Modern Infrastructure Patterns
Comments