The Workers runtime is workerd (open source), running V8 isolates at the edge. It implements web-platform APIs (Request/Response/fetch/URL/crypto.subtle/streams) — not Node, unless nodejs_compat is set. Workers use ES module format; the legacy service-worker (addEventListener("fetch")) format is deprecated.
export default {
async fetch(request, env, ctx) { /* HTTP requests */ },
async scheduled(event, env, ctx) { /* cron triggers */ },
async queue(batch, env, ctx) { /* queue consumer */ },
async email(message, env, ctx) { /* Email Workers */ },
async tail(events, env, ctx) { /* Tail Worker — traces of another Worker */ },
};
env — all bindings, vars, secrets.ctx.waitUntil(p) — keep the isolate alive for background work after the response is returned (analytics, cache writes).ctx.passThroughOnException() — on an unhandled error, fall through to origin instead of erroring.{ "triggers": { "crons": ["0 0 * * *", "*/15 * * * *"] } }
async scheduled(event, env, ctx) {
ctx.waitUntil(cleanup(env)); // event.cron tells you which schedule fired
}
Test locally: wrangler dev --test-scheduled then curl "localhost:8787/__scheduled?cron=0+0+*+*+*".
const CORS = {
"Access-Control-Allow-Origin": "https://app.example.com", // prefer an explicit origin over "*"
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export default {
async fetch(request) {
if (request.method === "OPTIONS") return new Response(null, { headers: CORS });
const res = Response.json({ ok: true });
for (const [k, v] of Object.entries(CORS)) res.headers.set(k, v);
return res;
},
};
If you send credentials, you cannot use * — echo a validated origin and add Access-Control-Allow-Credentials: true.
async fetch(request, env, ctx) {
const cache = caches.default;
let res = await cache.match(request);
if (!res) {
res = await fetch(request);
res = new Response(res.body, res);
res.headers.set("Cache-Control", "public, max-age=3600");
ctx.waitUntil(cache.put(request, res.clone())); // clone — body is single-use
}
return res;
}
The Cache API is per-colo (not global). For global caching use Cloudflare's CDN/Cache Rules at the zone level, or KV for app-controlled cache.
// Stream rather than buffer large bodies
const { readable, writable } = new TransformStream();
streamInto(writable); // write chunks asynchronously
return new Response(readable, { headers: { "Content-Type": "application/octet-stream" } });
Response accepts a ReadableStream; stream from R2 (obj.body) or fetch directly to keep memory flat.
async fetch(request) {
if (request.headers.get("Upgrade") !== "websocket")
return new Response("expected websocket", { status: 426 });
const [client, server] = Object.values(new WebSocketPair());
server.accept();
server.addEventListener("message", (e) => server.send(`echo: ${e.data}`));
return new Response(null, { status: 101, webSocket: client });
}
For stateful/multi-client sockets (chat, presence) terminate them in a Durable Object and use the WebSocket Hibernation API so idle connections don't bill compute.
Streams are single-use. To read a body twice, clone() before the first read:
const copy = request.clone();
const text = await request.text();
const json = await copy.json();
async fetch(request, env, ctx) {
try {
return Response.json({ data: await work(env) });
} catch (err) {
console.error("worker error", err); // captured by Workers Logs when observability is on
return Response.json({ error: err.message }, { status: 500 });
}
}
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 5000);
try {
return await fetch(url, { signal: ctrl.signal });
} catch (e) {
if (e.name === "AbortError") return new Response("upstream timeout", { status: 504 });
throw e;
} finally { clearTimeout(t); }
| Limit | Free | Paid |
|---|---|---|
| CPU time / invocation | 10 ms default, configurable | up to 30 s (raised from the old 50 ms; set via limits config) |
| Script size (gzipped) | 3 MB | 10 MB |
| Subrequests / invocation | 50 | 1000+ |
| Memory | 128 MB | 128 MB |
| Env vars + secrets | bounded | bounded |
"limits": { "cpu_ms": 50 } (or higher on paid)."compatibility_flags": ["nodejs_compat"] enables a subset of Node built-ins (node:crypto, node:buffer, node:async_hooks, streams, etc.). Not everything is polyfilled — verify the specific module is supported rather than assuming. Many "Node" npm packages work once this flag is on; some assume fs/native addons and won't.
Wrangler bundles with esbuild. Pitfalls: CommonJS-only packages, packages that reach for Node natives, and large transitive deps blowing the size limit. Prefer Workers-/edge-labelled libraries; dynamic-import heavy modules so they're only pulled when used; inspect the build output if a deploy is unexpectedly large.