Edge Functions with Cloudflare Workers: KV, D1, Caching and Wrangler Deploys
Key takeaways
How Cloudflare Workers execute code in V8 isolates close to users, and how to use them well: a small router, KV and its eventual consistency, D1 with migrations, the Cache API and where it silently does nothing, secrets, and deploying with Wrangler.
How Workers actually run your code
A Cloudflare Worker is a JavaScript (or WebAssembly) module that Cloudflare runs in its data centers, in front of or instead of an origin server. The key design decision is that Workers do not run in containers or VMs per customer. They run in V8 isolates: lightweight sandboxes inside a shared runtime process, the same mechanism a browser uses to keep tabs apart.
Most of the platform’s trade-offs follow from that choice:
- Startup is fast. Creating an isolate is much cheaper than booting a container, so the “cold start” problem common on container-based serverless platforms is far smaller. It is not zero: a large bundle with a lot of top-level initialization still takes time to load.
- You get Web APIs, not Node.
fetch,Request,Response,URL,crypto.subtle, streams andcachesare built in. There is no filesystem and no long-running process. Thenodejs_compatcompatibility flag adds many Node modules, but packages that expect a real OS (native addons,child_process, writing temp files) will not work. - Limits are about CPU time. A Worker can wait a long time on
fetch, KV or D1, but the CPU time it spends per request is capped, with the exact limit depending on your plan. Image processing, big JSON transforms and crypto loops hit that cap; waiting on I/O does not. - Global state is unreliable. A module-level variable persists for as long as that isolate lives, which might be many requests or one. Many isolates run in parallel across locations. A global counter or in-memory cache is fine as an optimization, never as a source of truth.
Workers are a good fit for API gateways, auth checks, redirects, A/B routing, HTML rewriting, webhooks and small JSON APIs backed by KV or D1. They are a poor fit for heavy computation or anything that needs a local disk.
Project setup
npm create cloudflare@latest my-worker
cd my-worker
npx wrangler dev # local dev server
# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2026-09-01"
compatibility_flags = ["nodejs_compat"]
[vars]
ENVIRONMENT = "production"
compatibility_date pins the runtime behavior your Worker was written against. Cloudflare ships behavior changes behind dates, so an old Worker keeps working when the runtime changes. Set it to the date you start the project, and move it forward deliberately, after testing, rather than copying an old date from a tutorial.
[vars] values are plain text and visible in the dashboard and in your repository. For API keys, use secrets:
npx wrangler secret put STRIPE_KEY
For local development, put secrets in a .dev.vars file (same KEY=value format as .env) and add it to .gitignore.
Routing and request handling
export interface Env {
MY_KV: KVNamespace;
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const route = `${request.method} ${url.pathname}`;
switch (route) {
case 'GET /':
return new Response('Home');
case 'GET /api/users':
return listUsers(env);
case 'POST /api/users':
return createUser(request, env);
default:
return new Response('Not Found', { status: 404 });
}
},
} satisfies ExportedHandler<Env>;
Matching on method and path together avoids a bug that shows up in many small examples. There, if (url.pathname === '/api/users') returns the GET response first, and the POST branch below it is never reached. For more than a handful of routes, a small router such as Hono gives you path parameters, middleware and typed bindings without much overhead.
Unhandled exceptions become a generic 500 error page. Wrap handlers that parse input in try/catch and return a 400 for bad JSON. Watch errors live with npx wrangler tail.
KV: fast reads, eventual consistency
[[kv_namespaces]]
binding = "MY_KV"
id = "<id from: npx wrangler kv namespace create MY_KV>"
// read
const value = await env.MY_KV.get('feature-flags', 'json');
// write with expiry
await env.MY_KV.put('session:abc', JSON.stringify(session), { expirationTtl: 3600 });
KV is built for data that is read far more often than it is written. Values are cached at the locations that read them. A write goes to central storage and then propagates. Cloudflare documents that a change can take up to about a minute to become visible everywhere, and a location that recently read a key may keep serving the cached value until it expires.
That design makes KV excellent for configuration, feature flags, redirects and rendered fragments. It makes KV the wrong choice for anything that needs read-after-write consistency across requests: counters, rate limits, inventory, or “did this user already redeem the coupon”. A failure mode I have run into is a rate limiter built on KV: get the count, add one, put it back. Under concurrent requests the increments overwrite each other, and reads in other locations see stale counts, so the limit leaks badly exactly when traffic spikes. That kind of state belongs in a Durable Object, which gives one strongly consistent instance per key, or in D1.
There is also a per-key write rate limit (about one write per second to the same key), so do not use a single KV key as a hot counter even in one location.
D1: SQLite at the edge
npx wrangler d1 create my-database
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "<id from the create command>"
-- migrations/0001_create_users.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
npx wrangler d1 migrations apply my-database --local # used by wrangler dev
npx wrangler d1 migrations apply my-database --remote # the deployed database
Local and remote are separate databases. A migration applied only locally is the usual reason a deployed Worker fails with an error containing no such table: users while everything works in development. Make --remote part of your deploy script, before wrangler deploy.
async function listUsers(env: Env): Promise<Response> {
const { results } = await env.DB.prepare(
'SELECT id, email, name FROM users ORDER BY id LIMIT 100',
).all();
return Response.json(results);
}
async function createUser(request: Request, env: Env): Promise<Response> {
let body: { email?: string; name?: string };
try {
body = await request.json();
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 });
}
if (!body.email) return Response.json({ error: 'email is required' }, { status: 400 });
try {
await env.DB.prepare('INSERT INTO users (email, name) VALUES (?, ?)')
.bind(body.email, body.name ?? null)
.run();
} catch (err) {
if (String(err).includes('UNIQUE constraint failed')) {
return Response.json({ error: 'email already exists' }, { status: 409 });
}
throw err;
}
return Response.json({ ok: true }, { status: 201 });
}
Always use ? placeholders with .bind(). D1 is SQLite, so the SQL dialect, types and constraint messages are SQLite’s. SELECT * without a LIMIT is fine in a tutorial, but D1 bills per row read, and a table scan on every request adds up. Add indexes for the columns you filter on. For several statements that must succeed or fail together, use env.DB.batch([...]), which runs them as one transaction.
The Cache API and where it does nothing
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 });
const cache = caches.default;
let response = await cache.match(request);
if (response) return response;
const upstream = await fetch('https://api.example.com/data');
response = new Response(upstream.body, upstream); // make headers mutable
response.headers.set('Cache-Control', 'public, s-maxage=300');
ctx.waitUntil(cache.put(request, response.clone())); // don't delay the response
return response;
},
} satisfies ExportedHandler<Env>;
A few rules explain most “my cache never hits” reports:
- The cache is per data center.
caches.defaultstores a response in the location that served the request. A user in another region misses until their location caches it too. That is fine for short-lived HTTP caching; use KV if you need a value available everywhere. - Cache operations have no effect on
*.workers.devsubdomains. Cloudflare documents this:cache.putsucceeds andcache.matchalways misses. Test caching on a route attached to your own zone. - Only GET requests are cacheable, and responses carrying
Set-CookieorCache-Control: private/no-storeare not stored. - Response headers from
fetchare immutable. Copying intonew Response(body, response)before callingheaders.setavoids aTypeError: Can't modify immutable headers.
Using ctx.waitUntil for the put lets the response go out immediately while the cache write finishes in the background. Without waitUntil, work that is still pending after you return the response may be cancelled.
Deploying
npx wrangler deploy
npx wrangler deploy --env staging # with [env.staging] in wrangler.toml
npx wrangler tail # stream live logs and exceptions
npx wrangler rollback # go back to the previous version
Named environments ([env.staging]) produce a separate Worker with its own bindings. Note that bindings such as KV namespaces and D1 databases are not inherited from the top level; each environment must declare its own. Otherwise staging silently has no env.DB, and the first query throws Cannot read properties of undefined.
I have also seen deploys that “worked” but served the wrong data because the staging environment pointed at the production D1 database id. The database_id was copied from the top-level block. Give each environment its own resources, and make the resource names say which environment they belong to.