LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Cloudflare Workers and the Edge: Building Globally Distributed Apps with Zero Cold Starts

cloudflareworkersedge-computingserverlessdurable-objectskvr2d1

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),
};

Request Transformation and Proxying

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;
}

Performance Patterns

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