🪴 GoDeep
Search
← Shipping Software

Workers and the edge runtime

What a Worker really is — a V8 isolate, not a Node process — how the fetch handler works, which Node APIs are missing and what `nodejs_compat` does about it, the CPU-time and subrequest limits, why bindings are the only way to reach storage, how Pages Functions differ from standalone Workers, and how to debug with `wrangler dev` and `wrangler tail`.

12 cards · 6 quiz questions · 11 min read

Somewhere in your 18 Pages projects there is a functions/ folder, and somewhere else a standalone Worker that queries D1 or signs an R2 URL. An agent wrote both, called them “serverless”, and moved on. The thing that will bite you is that a Worker looks like Node — it is JavaScript, it has fetch, npm packages install fine — but it is not Node, and the gap only shows up in production.

A Worker is a V8 isolate, not a process

A V8 isolate is a sandboxed JavaScript execution context: the same primitive that keeps one Chrome tab from reading another’s memory. Cloudflare runs one JavaScript runtime per machine and hosts hundreds or thousands of isolates inside it, so the cost of starting a runtime is paid once per machine instead of once per function. Their docs put the difference starkly: an isolate can start around a hundred times faster than a Node process on a container or virtual machine, and uses an order of magnitude less memory at startup.

That is where the speed comes from, and also where every restriction comes from. Your code has no operating system underneath it. No disk is mounted. No port is opened. Nothing survives between requests.

The handler

The whole entry point is this:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === "/api/health") {
      return Response.json({ ok: true });
    }
    // Keep background work alive after the response is returned
    ctx.waitUntil(logToAnalytics(request));
    return new Response("Not found", { status: 404 });
  },
};
  • request is the incoming request.
  • env carries your bindings and variables. It is not process.env.
  • ctx gives you ctx.waitUntil(promise), which keeps a promise alive after the response is sent (up to about 30 seconds), and ctx.passThroughOnException().

There is no app.listen(), no middleware stack, no server object. A request arrives, your function returns a Response, the isolate goes idle.

What is missing, and what nodejs_compat does

The nodejs_compat compatibility flag exposes a subset of Node’s standard library, partly as native runtime APIs and partly as polyfills. Buffer, crypto, path, stream, url, events, AsyncLocalStorage and zlib are all there. For compatibility dates between 2024-09-23 and 2026-08-03 you must add it explicitly; from 2026-08-04 onwards it is on by default.

// wrangler.jsonc
{
  "name": "respondentvoice-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01",
  "compatibility_flags": ["nodejs_compat"],   // optional from 2026-08-04
  "d1_databases": [
    { "binding": "DB", "database_name": "respondentvoice-db", "database_id": "..." }
  ],
  "kv_namespaces": [{ "binding": "SESSIONS", "id": "..." }],
  "r2_buckets": [{ "binding": "EXPORTS", "bucket_name": "exports" }]
}

What the flag does not give you is a Node process. node:child_process, node:worker_threads, node:vm and node:cluster are non-functional stubs — they import without error and then do nothing useful. os, dns and tls are only partly implemented. And a node:fs that technically resolves is not a disk you can persist to between requests.

So this pairing is a reliable smell:

// Before — an agent's "export" endpoint. No disk exists here.
import fs from "node:fs/promises";
export default {
  async fetch(request, env) {
    const csv = await buildCsv(env.DB);
    await fs.writeFile("/tmp/export.csv", csv);      // goes nowhere useful
    return new Response("saved");
  },
};
// After — write to R2 through a binding, return a key the caller can fetch
export default {
  async fetch(request, env) {
    const csv = await buildCsv(env.DB);
    const key = `exports/${new Date().toISOString()}.csv`;
    await env.EXPORTS.put(key, csv, {
      httpMetadata: { contentType: "text/csv" },    // set on the stored object
    });
    return Response.json({ key });
  },
};

Bindings are the only door to storage

Cloudflare describes a binding as “a permission and an API in one piece”. You declare it in your Wrangler config and it arrives at runtime as a live object on env: env.DB.prepare(...) for D1, env.SESSIONS.get(...) for KV, env.AUDIO.put(...) for R2. The important property is that the underlying credential is never exposed to your code, so there is no connection string to leak into a log line or a stack trace, and the grant is scoped to that one resource.

Which means this is a real finding, not a style nit:

// Before — agent reached for a REST call with a hand-managed API token
const res = await fetch(
  `https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}/storage/kv/namespaces/${env.NS}/values/${key}`,
  { headers: { Authorization: `Bearer ${env.CF_API_TOKEN}` } }   // broad token, in your code
);
// After — a binding: scoped, faster, no secret in flight
const value = await env.SESSIONS.get(key, { type: "json" });

The first version also burns a subrequest and a network round trip for something the runtime can do locally.

The limits that actually matter

LimitFreePaid
CPU time per invocation10 msup to 5 min (default 30 s)
Subrequests per invocation5010,000 (raisable)
Memory per isolate128 MB128 MB
Simultaneous connections awaiting headers66
Worker size, uncompressed64 MiB64 MiB

CPU time is not wall-clock time. It counts only the milliseconds your code spends computing; time awaiting a fetch() or a D1 query is not charged against it. There is no hard duration limit for an HTTP-triggered Worker as long as the client stays connected. So an endpoint that awaits six slow APIs is comfortable, while one that JSON.parses a 20 MB body and maps over it will blow the CPU limit even though it feels fast.

A subrequest is any fetch() or any call to a Cloudflare service, including R2, KV and D1. Each hop of a redirect chain counts separately, so the real number can exceed the fetch() calls you wrote. The six-connection cap is a separate, quieter constraint: a Promise.all over 200 URLs does not fan out 200 ways, it queues.

Cold starts and the global-state trap

Isolates make cold starts much less painful than container serverless, because a new isolate is created inside an already-running runtime. What remains is the cost of evaluating your module’s top-level code the first time an isolate serves a request. A 3 MB bundle that builds lookup tables at import time does add latency; keep top-level work cheap.

The related trap is more dangerous, because it works in testing:

// Before — per-isolate, transient, unshared. Works on your laptop.
const rateLimits = new Map();
export default {
  async fetch(request, env) {
    const ip = request.headers.get("CF-Connecting-IP");
    const n = (rateLimits.get(ip) ?? 0) + 1;
    rateLimits.set(ip, n);                     // lost whenever this isolate dies
    if (n > 100) return new Response("Slow down", { status: 429 });
    return handle(request, env);
  },
};
// After — shared state in KV, with a TTL doing the expiry for you
export default {
  async fetch(request, env) {
    const ip = request.headers.get("CF-Connecting-IP");
    const n = Number((await env.RATE_LIMITS.get(ip)) ?? 0) + 1;
    await env.RATE_LIMITS.put(ip, String(n), { expirationTtl: 60 }); // seconds
    if (n > 100) return new Response("Slow down", { status: 429 });
    return handle(request, env);
  },
};

Cloudflare’s own guidance is that isolates are not necessarily persistent — they can be evicted for resource or security reasons — so storing mutable global state is not recommended. Requests are also spread across isolates in many locations, so even while alive, the map only ever sees a slice of traffic.

Pages Functions versus standalone Workers

Same runtime, different packaging.

A Pages Function lives in a functions/ directory beside your static build and is routed by filename. functions/api/hello.js serves /api/hello. One set of brackets matches a single segment, two sets match any depth:

// functions/api/reports/[id].js  ->  /api/reports/anything
export async function onRequest(context) {
  const { params, env, request } = context;
  const row = await env.DB
    .prepare("SELECT * FROM reports WHERE id = ?")
    .bind(params.id)                   // never interpolate into SQL
    .first();
  if (!row) return context.next();     // fall through to the asset server
  return Response.json(row);
}

The context object carries request, env, params, waitUntil, passThroughOnException, data and next. If no Function matches a path, Pages falls back to a static asset.

A standalone Worker has the single fetch(request, env, ctx) entry point and you do the routing yourself, usually with a small router library. The practical reasons to choose it: Cron Triggers, Queue consumers, remote development and fuller observability are Workers features that Pages does not have. If an agent has built you a “nightly refresh” as a Pages Function poked by an external cron service, a standalone Worker with a Cron Trigger is the honest version.

Debugging: wrangler dev and wrangler tail

# Local runtime, local simulated bindings — fast, no network dependency
npx wrangler dev --local --port 8787

# Local code, real bindings and real data on Cloudflare's network
npx wrangler dev --remote

# Live logs and exceptions from the DEPLOYED Worker
npx wrangler tail policynews-api --format pretty
npx wrangler tail policynews-api --status error         # only failed invocations
npx wrangler tail policynews-api --search "D1_ERROR"    # match console.log text

The distinction to keep straight: wrangler dev runs your local code, even with --remote. Only wrangler tail tells you what the deployed Worker is doing. Tail is a live stream, not a log archive — it shows what happens while you watch, so reproduce the problem with the tail already open. For persistent history you need Workers Logs or an Analytics Engine binding.

What belongs in a Worker, and what does not

The edge is excellent at work that is small, I/O-bound and better done near the user: routing, auth checks, reading a session from KV, a single indexed D1 query, signing an R2 URL, stitching a couple of upstream responses, serving cached content.

It is the wrong place for: parsing a 200 MB survey export (128 MB memory, real CPU time), generating large PDFs, anything needing pandas or another native library, long-lived transactions, a persistent connection pool to a traditional Postgres, or a job that must run for ten minutes regardless of whether anyone is connected. That work belongs on your FastAPI service in an Azure Container App, and the Worker’s job is to hand off to it.

A useful rule: if the code needs to hold something — a file, a connection, a big object, a lock — it does not belong in an isolate.

What to check in your own code

  • grep -R "node:fs\|child_process\|worker_threads" functions/ src/ in every Worker repo. Any hit is either dead code or a bug.
  • Search for module-scope new Map(, new Set(, let cache, or let counter outside the handler. Each is a per-isolate variable pretending to be shared state.
  • Check wrangler.jsonc (or .toml) for a compatibility_date. An ancient date silently opts you out of newer runtime behaviour.
  • Confirm every storage access goes through env.<BINDING>. A hand-rolled fetch to api.cloudflare.com with a bearer token means a broad API token is sitting in your config.
  • Look for any Promise.all over more than about six fetch calls and remember the six-connection cap before you treat it as parallel.
  • Look for synchronous heavy work — JSON.parse of a large body, a nested loop over thousands of rows — and ask whether it belongs in FastAPI instead.
  • Reproduce a production 500 with wrangler tail <worker> --status error open before you start guessing.

Sources

  • Cloudflare Docs — How Workers works website The isolate model, the claim that an isolate starts around a hundred times faster than a Node process, and the warning that isolates can be evicted so global mutable state is unsafe.
  • Cloudflare Docs — Workers limits website 10 ms CPU on Free versus 5 min max on Paid (30 s default), 50 versus 10,000 subrequests per invocation, 128 MB memory, six simultaneous connections, and the 30-second waitUntil extension.
  • Cloudflare Docs — Node.js compatibility website What the `nodejs_compat` flag enables, which modules are fully or partially supported, and which are non-functional stubs (`node:child_process`, `node:worker_threads`, `node:vm`, `node:cluster`).
  • Cloudflare Docs — Bindings website Bindings as 'a permission and an API in one piece', accessed through `env`, with the underlying secret never exposed to your code.
  • Cloudflare Docs — Pages Functions routing website File-based routing in `functions/`, single- and double-bracket dynamic segments, and the fall-back to a static asset when no Function matches.