Edge Computing with Cloudflare Workers: A Practical Guide
A practical guide to building on Cloudflare Workers, covering the V8 isolate mental model, key storage primitives (KV, Durable Objects, D1, R2, Queues), real use cases like API gateways, auth, image processing, and caching, along with performance characteristics and production patterns.
Edge computing gets sold as a universal performance upgrade. Move code closer to users, latency drops, everyone wins. That framing is not wrong, but it is incomplete. Cloudflare Workers is a genuinely useful platform, but the cases where it earns its place are specific, and the constraints are real. Understanding both before you commit to a Workers-based architecture saves you from redesigning under pressure.
This guide covers the runtime model, the storage primitives, and the patterns that actually work in production.
The Mental Model Shift
Workers does not run in containers. It does not run Node.js. Your code runs inside V8 isolates, the same JavaScript engine that powers Chrome, without the browser DOM or the Node.js standard library.
V8 isolates are not processes. They are lightweight execution contexts that share a V8 instance. This is why Workers has no meaningful cold start: there is no container to provision and no process to fork. The isolate spins up in microseconds and your code executes.
The tradeoff is a strict, stateless execution model. Each request gets its own isolate. Isolates cannot share memory. There is no persistent in-process state between requests. If you are coming from a long-running server mindset (connection pools, in-memory caches that survive across requests, background threads), you need to rethink where that state lives.
The Execution Context
A Worker exports a fetch handler that receives a Request and must return a Response:
interface Env {
KV: KVNamespace;
DB: D1Database;
BUCKET: R2Bucket;
QUEUE: Queue;
API_SECRET: string;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Route to handlers
if (url.pathname.startsWith("/api/v1/")) {
return handleApi(request, env, ctx);
}
return new Response("Not Found", { status: 404 });
},
};
The env parameter carries your bindings: storage namespaces, secrets, service bindings to other Workers. The ctx object provides ctx.waitUntil(promise), which lets you run async work after the response is sent. This is how you do logging, analytics writes, or cache warming without blocking the client.
What Is Not Available
The constraints are not bugs. They are the reason the platform scales:
- No raw TCP. Database connections require an HTTP or WebSocket proxy layer. Cloudflare Hyperdrive handles connection pooling for Postgres and MySQL.
- No file system. Assets are bundled at build time or served from R2.
- CPU time is capped at 30 seconds on paid plans (measured as actual script execution time, not wall-clock). Waiting on
fetch()or storage reads does not consume CPU budget. - Memory per isolate is capped at 128MB.
- Node.js compatibility is partial. The
nodejs_compatflag unlocksnode:crypto,node:buffer,node:stream, and a handful of others. It does not covernode:fs,node:net, or native modules.
Test your npm dependencies early with wrangler dev. Libraries that assume a full Node.js environment will fail in ways that are not always obvious at deploy time.
The Storage Primitives
Workers KV
KV is a globally distributed key-value store. Reads are fast because values are cached at the edge closest to the requester. Writes propagate worldwide within about 60 seconds.
Use KV for:
- Feature flags and configuration (read-heavy, rarely written)
- A/B test variant assignments
- User preferences and personalization data
- API response caching
Do not use KV for:
- Counters you need to increment accurately across multiple writers
- Anything requiring strong consistency after a write
- Write-heavy workloads (writes cost more and the propagation window causes stale reads)
A useful pattern: cache KV reads in module-level variables within the isolate. Isolates are short-lived, but within a single isolate handling multiple requests (Workers reuses isolates under load), you can avoid redundant KV reads:
// Module-level cache: lives for the lifetime of the isolate, not the request
let configCache: AppConfig | null = null;
let configFetchedAt = 0;
const CONFIG_TTL_MS = 30_000; // 30 seconds
async function getConfig(env: Env): Promise<AppConfig> {
const now = Date.now();
if (configCache && now - configFetchedAt < CONFIG_TTL_MS) {
return configCache;
}
const raw = await env.KV.get("app-config", { type: "json" });
configCache = (raw as AppConfig) ?? defaultConfig;
configFetchedAt = now;
return configCache;
}
Durable Objects
Durable Objects solve the problem that KV cannot: consistent, stateful coordination across requests. Each Durable Object instance has a single-threaded execution model and its own durable storage. All requests routed to the same instance are serialized, which means no race conditions.
The primary use cases are real-time collaboration, presence systems, per-entity rate limiting, and any scenario where you need one authoritative writer for a given entity:
export class DocumentSession implements DurableObject {
private state: DurableObjectState;
private sessions: Map<string, WebSocket> = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
// Hibernate websockets across isolate restarts
this.state.getWebSockets().forEach((ws) => {
const meta = ws.deserializeAttachment() as { sessionId: string };
this.sessions.set(meta.sessionId, ws);
});
}
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get("Upgrade");
if (upgradeHeader !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const [client, server] = Object.values(new WebSocketPair());
const sessionId = crypto.randomUUID();
// Attach metadata so we can recover after hibernation
server.serializeAttachment({ sessionId });
this.state.acceptWebSocket(server);
this.sessions.set(sessionId, server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
// Broadcast to all other sessions in this document
const senderMeta = ws.deserializeAttachment() as { sessionId: string };
this.sessions.forEach((otherWs, otherId) => {
if (otherId !== senderMeta.sessionId && otherWs.readyState === WebSocket.OPEN) {
otherWs.send(message);
}
});
}
}
The tradeoff: a Durable Object lives in one physical location. Writes from geographically distant clients take the full round-trip to that location. For real-time features where correctness matters more than global write latency, this is the right tradeoff.
D1: SQLite at the Edge
D1 is Cloudflare’s managed SQLite offering. It runs close to your Workers and supports standard SQL queries through a lightweight binding API. It is not a distributed database, but for read-heavy workloads with a regional primary, it covers a wide range of application needs:
interface User {
id: string;
email: string;
created_at: string;
}
async function getUserById(env: Env, userId: string): Promise<User | null> {
const result = await env.DB.prepare(
"SELECT id, email, created_at FROM users WHERE id = ? LIMIT 1"
)
.bind(userId)
.first<User>();
return result ?? null;
}
async function createUser(env: Env, id: string, email: string): Promise<void> {
await env.DB.prepare(
"INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)"
)
.bind(id, email, new Date().toISOString())
.run();
}
D1 uses D1’s read replication to serve reads from the region nearest to your Worker. Writes go to the primary. For applications where reads dominate (user profiles, content lookup, configuration), D1 is practical without the operational overhead of Postgres.
The failure mode to watch: D1 is not appropriate for high-write workloads or complex transactions across multiple tables. If your write volume is significant or you need serializable isolation across concurrent writes, use Hyperdrive with Postgres.
R2: Object Storage Without Egress Costs
R2 is S3-compatible object storage. The key difference from S3: no egress fees for data served through Workers. This matters when you are serving large assets to geographically distributed users.
A production-ready asset serving Worker:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response("Method Not Allowed", { status: 405 });
}
const url = new URL(request.url);
// Strip leading slash, prevent path traversal
const key = decodeURIComponent(url.pathname.slice(1)).replace(/\.\./g, "");
if (!key) {
return new Response("Not Found", { status: 404 });
}
// Support conditional requests with ETags
const ifNoneMatch = request.headers.get("If-None-Match");
const object = await env.BUCKET.get(key, {
onlyIf: ifNoneMatch ? { etagDoesNotMatch: ifNoneMatch } : undefined,
});
if (!object) {
return new Response("Not Found", { status: 404 });
}
// 304 Not Modified when ETag matches
if (object.body === null) {
return new Response(null, { status: 304 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("ETag", object.httpEtag);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
return new Response(object.body, { headers });
},
};
This handles conditional requests correctly, which is important for reducing bandwidth when clients already have a cached version.
Queues: Background Work at the Edge
Workers Queues gives you durable message queues with at-least-once delivery. A Worker can produce messages to a queue; a consumer Worker processes them asynchronously.
This is useful for decoupling latency-sensitive request handlers from slow operations: sending emails, calling third-party webhooks, writing analytics events, triggering background jobs.
interface EmailJob {
to: string;
subject: string;
templateId: string;
variables: Record<string, string>;
}
// Producer: the request handler enqueues the job and responds immediately
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { userId, action } = await request.json<{ userId: string; action: string }>();
const user = await getUserById(env, userId);
if (!user) {
return new Response("Not Found", { status: 404 });
}
// Enqueue without waiting: ctx.waitUntil ensures it completes
ctx.waitUntil(
env.QUEUE.send({
to: user.email,
subject: "Action confirmation",
templateId: `action-${action}`,
variables: { userId, action, timestamp: new Date().toISOString() },
} satisfies EmailJob)
);
return Response.json({ ok: true });
},
};
// Consumer: a separate Worker bound as a queue consumer
export default {
async queue(batch: MessageBatch<EmailJob>, env: Env): Promise<void> {
for (const message of batch.messages) {
const { to, subject, templateId, variables } = message.body;
try {
await sendEmail(env, { to, subject, templateId, variables });
message.ack();
} catch (err) {
// Retry on failure; message returns to queue
message.retry();
}
}
},
};
ctx.waitUntil is important here: it tells the Workers runtime to keep the isolate alive until the enqueue completes, even after the response is sent. Without it, the runtime may terminate the isolate before the queue write finishes.
Real Use Cases
API Gateway
A Workers-based API gateway is one of the cleaner architectures for multi-origin setups. You get a single entry point, auth in one place, and routing logic that does not require a separate load balancer:
const routes: Array<{ pattern: URLPattern; origin: string }> = [
{ pattern: new URLPattern({ pathname: "/api/users/*" }), origin: "https://users-service.internal" },
{ pattern: new URLPattern({ pathname: "/api/billing/*" }), origin: "https://billing-service.internal" },
{ pattern: new URLPattern({ pathname: "/api/content/*" }), origin: "https://content-service.internal" },
];
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Auth check runs at the edge, before touching any origin
const user = await validateToken(request, env);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const url = new URL(request.url);
const route = routes.find((r) => r.pattern.test(url));
if (!route) {
return new Response("Not Found", { status: 404 });
}
// Forward to origin with user context injected
const originUrl = new URL(request.url);
originUrl.hostname = new URL(route.origin).hostname;
const originRequest = new Request(originUrl.toString(), request);
originRequest.headers.set("X-User-Id", user.id);
originRequest.headers.set("X-User-Role", user.role);
return fetch(originRequest);
},
};
Every unauthenticated request is rejected before it reaches your origin services. The origins can trust the injected headers because requests only arrive through the Worker.
Auth at the Edge
JWT validation is stateless and CPU-light, which fits Workers well. The JWKS fetch is cached in-memory after the first request:
import { jwtVerify, createRemoteJWKSet, JWTPayload } from "jose";
// Module-level: cached for the lifetime of the isolate
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJWKS(env: Env): ReturnType<typeof createRemoteJWKSet> {
if (!jwks) {
jwks = createRemoteJWKSet(new URL(`${env.AUTH_ISSUER}/.well-known/jwks.json`));
}
return jwks;
}
interface AuthClaims extends JWTPayload {
sub: string;
role: string;
}
async function validateToken(
request: Request,
env: Env
): Promise<AuthClaims | null> {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify<AuthClaims>(token, getJWKS(env), {
issuer: env.AUTH_ISSUER,
audience: env.AUTH_AUDIENCE,
});
return payload;
} catch {
return null;
}
}
On the first request to a fresh isolate, the JWKS endpoint is fetched and cached in the jwks variable. Subsequent requests in the same isolate reuse it. The jose library works in Workers without polyfills because it uses the Web Crypto API, which is available in the Workers runtime.
Image Processing
Workers can do lightweight image transforms: resizing, format conversion, quality adjustment. For heavy transforms, use Cloudflare Images or an external service. For simple cases, the Workers fetch API exposes Cloudflare’s image resizing:
interface TransformOptions {
width?: number;
height?: number;
format?: "webp" | "avif" | "jpeg" | "png";
quality?: number;
fit?: "contain" | "cover" | "crop" | "pad";
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// /images/photo.jpg?w=800&h=600&format=webp&q=85
const imageKey = url.pathname.replace("/images/", "");
const options: TransformOptions = {
width: Number(url.searchParams.get("w")) || undefined,
height: Number(url.searchParams.get("h")) || undefined,
format: (url.searchParams.get("format") as TransformOptions["format"]) || "webp",
quality: Number(url.searchParams.get("q")) || 85,
fit: "cover",
};
// Check cache before transforming
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
// Fetch original from R2
const original = await env.BUCKET.get(imageKey);
if (!original) return new Response("Not Found", { status: 404 });
// Use Cloudflare's image resizing (requires Images enabled on the zone)
const imageUrl = `https://${url.hostname}/cdn-cgi/image/${buildTransformString(options)}/${imageKey}`;
const transformed = await fetch(imageUrl);
// Cache the result at the edge for 1 hour
const response = new Response(transformed.body, transformed);
response.headers.set("Cache-Control", "public, max-age=3600");
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
function buildTransformString(options: TransformOptions): string {
return Object.entries(options)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}=${v}`)
.join(",");
}
This requires Cloudflare Images to be enabled on the zone. For teams not using Cloudflare Images, a Worker can fetch from R2 and return the original, with a CDN layer handling caching. The transformation itself happens at the Cloudflare layer, not in your Worker’s CPU budget.
Caching Strategies
Workers has access to the Cloudflare cache API (caches.default). This lets you cache arbitrary responses at the edge, including API responses that your origin generates:
async function cachedFetch(
request: Request,
env: Env,
ctx: ExecutionContext,
ttlSeconds: number
): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url, { method: "GET" });
const cached = await cache.match(cacheKey);
if (cached) {
// Return cached response with header indicating cache hit
return new Response(cached.body, {
...cached,
headers: new Headers({ ...Object.fromEntries(cached.headers), "X-Cache": "HIT" }),
});
}
const response = await fetch(request);
if (response.ok) {
const toCache = new Response(response.clone().body, response);
toCache.headers.set("Cache-Control", `public, max-age=${ttlSeconds}`);
ctx.waitUntil(cache.put(cacheKey, toCache));
}
return new Response(response.body, {
...response,
headers: new Headers({ ...Object.fromEntries(response.headers), "X-Cache": "MISS" }),
});
}
The ctx.waitUntil on cache.put is critical. Without it, the cache write may be abandoned when the response is sent. By passing it to waitUntil, you guarantee the write completes even though the client already has their response.
For stale-while-revalidate behavior, check the cached response’s Age header and trigger a background refresh if it exceeds your threshold. This gives users fast responses from cache while the origin stays current.
Performance Characteristics
Workers on the paid plan ($5/month base) costs $0.30 per million requests and $0.02 per million GB-seconds of CPU time. For routing and thin API logic, the request cost dominates and CPU costs are negligible.
Latency characteristics under normal conditions:
- A Worker returning a static response: 5-15ms globally (p50)
- A Worker reading from KV: add 10-30ms for the KV lookup
- A Worker querying D1: add 10-40ms for a simple indexed query
- A Worker calling a Durable Object in a different region: round-trip to the DO location (variable, 20-200ms depending on geography)
For comparison: AWS Lambda p99 cold starts range from 100ms to several seconds depending on runtime. Workers has no meaningful cold start because isolates do not require process provisioning. At scale, that difference in p99 latency has real impact on user experience for latency-sensitive endpoints.
Structuring a Production Worker Project
A Workers project tends to grow in complexity quickly if you put everything in one file. A structure that scales:
src/
index.ts # Entry point, top-level routing
handlers/
api.ts # API route handlers
assets.ts # Static asset serving
webhooks.ts # Incoming webhook handling
middleware/
auth.ts # JWT validation
ratelimit.ts # Rate limiting via Durable Objects
logging.ts # Request logging
lib/
cache.ts # Cache utilities
db.ts # D1 query helpers
queue.ts # Queue producers
types.ts # Env interface, shared types
wrangler.toml # Bindings, routes, compatibility flags
The Env interface in types.ts is the source of truth for all bindings:
// types.ts
export interface Env {
// Storage
KV: KVNamespace;
DB: D1Database;
BUCKET: R2Bucket;
QUEUE: Queue;
// Durable Objects
RATE_LIMITER: DurableObjectNamespace;
DOC_SESSION: DurableObjectNamespace;
// Secrets
JWT_SECRET: string;
AUTH_ISSUER: string;
AUTH_AUDIENCE: string;
}
Declaring bindings in wrangler.toml and typing them in Env gives you type-safe access everywhere without casting.
For local development, wrangler dev runs the full Workers runtime locally including KV, D1, R2, and Queues emulation. The local environment is close enough to production that most bugs surface before deployment.
Limitations Worth Knowing
A few constraints that catch teams off guard:
Single region for Durable Objects: A Durable Object instance lives in one location. If your users are worldwide and you’re using a DO per user for rate limiting, users far from the DO’s location see higher write latency. Cloudflare is building regional DO support, but it is not yet generally available.
No streaming response bodies from D1: D1 returns full result sets in memory. For large query results, use LIMIT and cursor-based pagination.
Queue delivery guarantees: Queues are at-least-once, not exactly-once. Your consumer should be idempotent, or you need to track processed message IDs in D1 or KV.
KV write limits: KV has a write limit of approximately 1 write per second per key for consistent propagation. For high-write keys, use Durable Objects instead.
wrangler.toml binding declarations are per-environment: If you have staging and production environments, each needs its own binding declarations in wrangler.toml. It is easy to forget to add a new binding to both environments.
Closing Thoughts
Cloudflare Workers is a mature platform for a specific class of problems. The V8 isolate model gives you global distribution and no cold starts at the cost of a strict execution environment. The ecosystem (KV, Durable Objects, D1, R2, Queues) covers most of what you need to build complete applications without leaving the platform.
The mistake is treating it as a drop-in replacement for everything. Workers wins at request routing, auth middleware, lightweight APIs, and globally distributed reads. It requires more care for write-heavy workloads, complex SQL, and workloads with heavy CPU requirements. Understand where your latency actually comes from before choosing a runtime. When the fit is right, it is one of the cleanest deployment targets in the current ecosystem.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.