Cloudflare Workers — Build and Deploy Serverless at the Edge
A hands-on guide to building serverless functions with Cloudflare Workers. Covers Wrangler setup, routing, KV storage, D1 databases, cron triggers, and real deployment patterns.
Most "serverless" platforms run your code in a single region. You pick us-east-1 or eu-west-1, and your function cold-starts from there. A user in Tokyo hits your endpoint and waits for a round trip to Virginia before anything happens.
Cloudflare Workers are different. Your code runs on Cloudflare's edge network -- over 300 locations worldwide. When someone in Tokyo makes a request, it executes on a server in Tokyo. Someone in Sao Paulo gets a server in Sao Paulo. There's no region selector because the answer is "all of them."
That's the pitch, anyway. The reality is genuinely impressive once you understand what Workers can and can't do.
What Workers Actually Are
Workers use the V8 JavaScript engine -- the same one Chrome uses -- but they don't run in Node.js. There's no fs module, no child_process, no Buffer (though there's a compatibility layer now). They run in an isolated environment based on the Service Worker API.
Each Worker gets:
- 10ms CPU time per request (free plan) or 50ms (paid)
- 128MB memory
- No cold starts (V8 isolates spin up in under 5ms, compared to 200-500ms for Lambda)
- Automatic global deployment
Setting Up
Install Wrangler, Cloudflare's CLI:
npm install -g wrangler
wrangler login
Create a new project:
npm create cloudflare@latest my-worker
cd my-worker
You'll get a wrangler.toml config and a src/index.ts:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return new Response("Hello from the edge");
},
};
Three arguments matter:
request-- standard Web API Request objectenv-- bindings to KV, D1, R2, secrets, and other servicesctx-- gives youwaitUntil()for background tasks after the response is sent
Run locally:
wrangler dev
This starts a local server that mimics the Workers runtime pretty accurately. It even supports KV and D1 locally.
Routing and Request Handling
Workers don't have a built-in router. You handle routing yourself:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/api/users" && request.method === "GET") {
return getUsers(env);
}
if (path === "/api/users" && request.method === "POST") {
return createUser(request, env);
}
if (path.startsWith("/api/users/")) {
const id = path.split("/")[3];
return getUser(id, env);
}
return new Response("Not found", { status: 404 });
},
};
For anything beyond a few routes, use a lightweight router. Hono is the most popular choice:
import { Hono } from "hono";
type Bindings = {
DB: D1Database;
KV: KVNamespace;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get("/api/users", async (c) => {
const results = await c.env.DB.prepare("SELECT * FROM users LIMIT 50").all();
return c.json(results);
});
app.post("/api/users", async (c) => {
const body = await c.req.json();
await c.env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)")
.bind(body.name, body.email)
.run();
return c.json({ success: true }, 201);
});
app.get("/api/users/:id", async (c) => {
const id = c.req.param("id");
const user = await c.env.DB.prepare("SELECT * FROM users WHERE id = ?")
.bind(id)
.first();
if (!user) return c.json({ error: "Not found" }, 404);
return c.json(user);
});
export default app;
Hono was literally designed for Cloudflare Workers. It's tiny (~14KB), fast, and has first-class types.
KV Storage — Global Key-Value
KV (Key-Value) is Cloudflare's globally distributed data store. It's eventually consistent -- writes propagate worldwide within 60 seconds. Reads are fast from everywhere.
Create a namespace:
wrangler kv namespace create MY_KV
Add the binding to wrangler.toml:
[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456"
Use it:
// Write
await env.MY_KV.put("user:123", JSON.stringify({ name: "Alice", role: "admin" }));
// Write with expiration (TTL in seconds)
await env.MY_KV.put("session:xyz", token, { expirationTtl: 3600 });
// Read
const data = await env.MY_KV.get("user:123", { type: "json" });
// List keys with prefix
const list = await env.MY_KV.list({ prefix: "user:" });
// Delete
await env.MY_KV.delete("user:123");
When to use KV:
- Configuration/feature flags
- Cached API responses
- Session tokens
- Rate limiting counters (approximate -- eventual consistency)
- Static asset metadata
- Anything requiring strong consistency (use D1 or Durable Objects)
- Frequently updated data (writes are expensive -- $5 per 1M writes)
- Complex queries (it's literally just key-value)
D1 — SQL at the Edge
D1 is Cloudflare's SQLite-based database. It runs at the edge and supports full SQL queries.
wrangler d1 create my-database
Add to wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxxx-xxxx-xxxx"
Create migrations:
wrangler d1 migrations create my-database init
Write the migration SQL:
-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_created ON posts(created_at DESC);
Apply it:
wrangler d1 migrations apply my-database # production
wrangler d1 migrations apply my-database --local # local dev
Query from your Worker:
// Single row
const post = await env.DB.prepare(
"SELECT * FROM posts WHERE id = ?"
).bind(postId).first();
// Multiple rows
const { results } = await env.DB.prepare(
"SELECT * FROM posts WHERE author_id = ? ORDER BY created_at DESC LIMIT ?"
).bind(authorId, limit).all();
// Insert
const info = await env.DB.prepare(
"INSERT INTO posts (title, content, author_id) VALUES (?, ?, ?)"
).bind(title, content, authorId).run();
// Batch operations (single round trip)
const results = await env.DB.batch([
env.DB.prepare("UPDATE posts SET title = ? WHERE id = ?").bind(newTitle, id),
env.DB.prepare("INSERT INTO audit_log (action, post_id) VALUES (?, ?)").bind("update", id),
]);
D1 is SQLite under the hood, so you get the full SQLite feature set: JSON functions, window functions, CTEs, full-text search.
Cron Triggers
Workers can run on a schedule without any HTTP request:
[triggers]
crons = ["0 /6 ", "0 0 MON"]
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Handle HTTP requests
return new Response("OK");
},
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
switch (event.cron) {
case "0 /6 ":
// Runs every 6 hours
await refreshCache(env);
break;
case "0 0 MON":
// Runs every Monday at midnight
await sendWeeklyReport(env);
break;
}
},
};
Common uses: cache warming, cleanup tasks, sending scheduled emails, aggregating analytics.
Middleware Pattern
You'll want middleware for auth, CORS, logging, and error handling. With Hono:
import { Hono } from "hono";
import { cors } from "hono/cors";
import { jwt } from "hono/jwt";
const app = new Hono<{ Bindings: Bindings }>();
// CORS
app.use("/api/*", cors({
origin: ["https://yoursite.com", "http://localhost:3000"],
allowMethods: ["GET", "POST", "PUT", "DELETE"],
}));
// Auth middleware
app.use("/api/*", async (c, next) => {
const token = c.req.header("Authorization")?.replace("Bearer ", "");
if (!token) {
return c.json({ error: "Unauthorized" }, 401);
}
try {
const payload = await verifyJWT(token, c.env.JWT_SECRET);
c.set("user", payload);
await next();
} catch {
return c.json({ error: "Invalid token" }, 401);
}
});
// Error handling
app.onError((err, c) => {
console.error(${c.req.method} ${c.req.url} - ${err.message});
return c.json({ error: "Internal server error" }, 500);
});
Environment Variables and Secrets
Don't hardcode API keys. Use secrets:
wrangler secret put API_KEY
# Prompts for the value, stores it encrypted
Access in code via env.API_KEY.
For non-sensitive config, use wrangler.toml:
[vars]
ENVIRONMENT = "production"
API_BASE_URL = "https://api.example.com"
Deploying
wrangler deploy
That's it. Your code is live on 300+ edge locations within seconds. No provisioning, no capacity planning, no load balancers.
Custom domains:
routes = [
{ pattern = "api.yoursite.com/*", zone_name = "yoursite.com" }
]
Or use a workers.dev subdomain for free.
Cost Comparison
| Plan | Requests | CPU Time | KV Reads | KV Writes | D1 Reads | D1 Writes |
|---|---|---|---|---|---|---|
| Free | 100K/day | 10ms/req | 100K/day | 1K/day | 5M/day | 100K/day |
| Paid ($5/mo) | 10M/mo included | 50ms/req | 10M/mo | 1M/mo | 25B/mo | 50M/mo |
Common Patterns
API proxy with caching:app.get("/api/weather/:city", async (c) => {
const city = c.req.param("city");
const cacheKey = weather:${city};
// Check KV cache first
const cached = await c.env.KV.get(cacheKey, { type: "json" });
if (cached) return c.json(cached);
// Fetch from upstream
const res = await fetch(
https://api.weather.com/v1/${city}?key=${c.env.WEATHER_KEY}
);
const data = await res.json();
// Cache for 30 minutes
await c.env.KV.put(cacheKey, JSON.stringify(data), { expirationTtl: 1800 });
return c.json(data);
});
Rate limiting with KV:
async function rateLimit(ip: string, env: Env): Promise<boolean> {
const key = rate:${ip};
const current = parseInt(await env.KV.get(key) || "0");
if (current >= 100) return false; // 100 requests per minute
await env.KV.put(key, String(current + 1), { expirationTtl: 60 });
return true;
}
Redirect map:
const redirects: Record<string, string> = {
"/old-page": "/new-page",
"/blog": "https://blog.yoursite.com",
"/docs": "https://docs.yoursite.com",
};
app.get("*", (c) => {
const target = redirects[c.req.path];
if (target) return c.redirect(target, 301);
return c.json({ error: "Not found" }, 404);
});
Pitfalls
No Node.js APIs:require('fs'), Buffer.from() (without polyfill), process.env -- none of these work by default. Use the nodejs_compat flag in wrangler.toml to get polyfills for common modules:
compatibility_flags = ["nodejs_compat"]
KV eventual consistency: If you write a value and immediately read it from a different region, you might get stale data. Don't use KV for anything requiring read-your-writes consistency.
CPU time limits: Parsing a 10MB JSON blob will eat through your CPU budget fast. Workers are for lightweight compute -- API routing, transformations, proxying. Don't run image processing or heavy number-crunching here.
Subrequest limits: Free plan gets 50 subrequests (fetch calls) per invocation. Paid gets 1000. Design accordingly.
Workers slot in perfectly as an API layer, BFF (backend-for-frontend), or edge middleware. For compute-heavy backends, you'll still want a traditional server. But for the glue between your frontend and your services, it's hard to beat running at the edge with zero cold starts. The CodeUp playground uses a similar edge architecture for its API routing, and the response times speak for themselves.