March 26, 20269 min read

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.

cloudflare workers serverless edge deployment
Ad 336x280

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
The CPU time limit sounds tiny, but it's _CPU_ time, not wall-clock time. Waiting for a fetch response doesn't count. You can make network calls that take 2 seconds total and still be well within limits.

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 object

  • env -- bindings to KV, D1, R2, secrets, and other services

  • ctx -- gives you waitUntil() 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
When NOT to use KV:
  • 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

PlanRequestsCPU TimeKV ReadsKV WritesD1 ReadsD1 Writes
Free100K/day10ms/req100K/day1K/day5M/day100K/day
Paid ($5/mo)10M/mo included50ms/req10M/mo1M/mo25B/mo50M/mo
For most projects and side projects, the free tier is more than enough. The paid plan at $5/month gives you headroom that would cost $50+ on AWS Lambda.

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.

Ad 728x90