workers-runtime.md 5.6 KB

Workers Runtime — handlers, APIs, patterns, limits

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.

Handlers

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.

Scheduled (cron)

{ "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+*+*+*".

CORS

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.

Cache API

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.

Streaming

// 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.

WebSockets

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.

Body reuse

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();

Error handling

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 });
  }
}

Subrequests, timeouts, abort

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); }

Limits (check the live limits page — these move)

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
  • CPU time ≠ wall-clock: awaiting I/O doesn't burn CPU budget; a tight compute loop does and gets killed.
  • Configure CPU limit explicitly: "limits": { "cpu_ms": 50 } (or higher on paid).

Node compatibility

"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.

Bundling

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.