>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Workers: No such module "node:buffer" (compat date fix)

Alex Rivera covers Cloudflare Workers and the serverless edge at C2CZ. Author page · All authors.

workerd stops with service main: Uncaught Error: No such module "node:buffer". and never binds a socket, so the first thing you see is curl: (7) Failed to connect to 127.0.0.1 port 19951 after 0 ms: Could not connect to server instead of a stack trace from your own code. The import is correct, the package exists in node_modules, and node:buffer is supported by the runtime — what is missing is a date. The set of Node.js built-in modules the runtime exposes is selected by compatibilityDate, and the Node.js module set only became the default on 2026-08-04. Pin a date before that and every node:* specifier is unresolvable; move the date one day forward and the same file boots untouched.

That single variable is worth proving rather than quoting, so I built the smallest harness that shows it: one workerd binary, one worker source file, one Cap'n Proto config, and the date as the only difference between the passing and failing run. Every string below is pasted from a run on this host — workerd 2026-09-15, Debian 13 on Linux 6.17 (aarch64), curl 8.14.1. The boundary is sharp, the two failure shapes are different, and one of them never reaches a try/catch you can write.

TL;DR

  • The error is thrown while modules are being resolved, before your fetch() handler is ever registered. There is no request, no 500, and no log line from your code — only the runtime's own line: service main: Uncaught Error: No such module "node:buffer". followed by imported from "worker.js".
  • The gate is compatibilityDate, and the measured boundary is exact:
    • compatibilityDate = "2026-08-03" → boot fails, socket never bound, client gets curl: (7) Failed to connect.
    • compatibilityDate = "2026-08-04" → identical source, no flags, response node:buffer ok, hex=6332637a.
  • The flag is not required on or after that date, and the runtime says so out loud when you keep it: service main: The compatibility flag nodejs_compat became the default as of 2026-08-04 so does not need to be specified anymore.
  • The import style decides the failure shape. A static import at the top of the file kills the process at boot; a dynamic await import() inside the handler returns a 500 from your own worker: dynamic import failed: Error: No such module "node:buffer". Same missing module, two different symptoms, two different places to look.
  • Bare specifiers fail the same way when the flag is off: service main: Uncaught Error: No such module "buffer". So does node:crypto. Renaming the specifier is not a workaround.
  • What you actually get from a pre-2026-08-04 date plus nodejs_compat is measured, not guessed: at 2026-08-03, globalThis.Buffer is function, node:buffer imports, typeof process is object and process.versions.node is 22.19.0 — but import.meta.url is still undefined. The version string has its own date gate: at 2025-08-01 the identical probe returns (undefined).
  • That last one breaks the usual CommonJS escape hatch: createRequire(import.meta.url) throws TypeError: The argument 'path' The argument must be a file URL object, a file URL string, or an absolute path string.. Received 'undefined'.
  • Fix in this order: raise compatibility_date to 2026-08-04 or later (drop the now-redundant flag); otherwise keep the old date and add nodejs_compat; only then consider removing the Node.js dependency altogether.
  • Guarding it costs one CI step: boot the worker under its pinned date and fail the build when the log matches No such module. Measured: exit 1 on 2026-08-03, exit 0 on 2026-08-04.

Why this is a boot failure and not an error you can catch

Module resolution happens before instantiation. workerd reads the config, sees esModule = embed "worker.js", starts resolving that module's imports, finds node:buffer absent from the module registry for the active compatibility date, and aborts the service. Nothing is listening on the socket afterwards, which is why the client-side symptom is a connection error rather than a response.

Three consequences follow, and they explain most of the confusion in bug reports about this string:

  • No HTTP status exists. There is no 500 to search for in the dashboard, no request log entry for the failing call, and curl exits 7 (connection refused) rather than receiving a body. If your first signal is a connection error on a port that you just started, read the server log, not the client output.
  • Application-level error handling is unreachable. A try/catch in fetch(), a top-level error boundary, a middleware onError in Hono — none of them run. The failure is upstream of instantiation.
  • The process is gone. The log line service main: Uncaught Error: is the runtime telling you the service was dropped. The main prefix is the service name from the config, so one config with several services can produce several differently-prefixed lines; the imported from "worker.js" line under it names the module doing the importing, which is how you find the offending file in a bundle with a dozen entries.

The static-versus-dynamic split deserves its own paragraph, because it changes where you look. When I replaced the top-level import with an await import("node:buffer") inside fetch(), the process stayed alive, the socket was bound, and the request returned HTTP 500 with a body my own code produced:

Import stylecompatibilityDateObserved result
import { Buffer } from "node:buffer" (static)2026-08-03Boot failure — curl: (7) Failed to connect; log: service main: Uncaught Error: No such module "node:buffer".
await import("node:buffer") (dynamic)2026-08-03HTTP 500 from the worker: dynamic import failed: Error: No such module "node:buffer".; empty server log
import { Buffer } from "node:buffer" (static)2026-08-04HTTP 200: node:buffer ok, hex=6332637a

So the same missing module can present as “the dev server will not start” or as “one endpoint returns 500 while the rest are fine”. If you are chasing the second shape, the server log will be silent and the only evidence is the error object from the rejected dynamic import — which, usefully, carries the same No such module text with the node: prefix intact.

The boundary, measured day by day

I ran the same source file across a spread of dates. The only edit between runs is the compatibilityDate string in the config; the worker, the module name and the port are identical:

compatibilityDatecompatibilityFlagsResult
2025-08-01noneBoot failure: No such module "node:buffer".
2025-08-01["nodejs_compat"]HTTP 200: node:buffer ok, hex=6332637a
2026-08-03noneBoot failure: No such module "node:buffer".
2026-08-04noneHTTP 200: node:buffer ok, hex=6332637a
2026-08-04["nodejs_compat"]HTTP 200, plus the log warning The compatibility flag nodejs_compat became the default as of 2026-08-04 so does not need to be specified anymore.
2026-08-04["nodejs_compat_v2"]HTTP 200, plus the same warning naming nodejs_compat_v2

Two practical readings of that table. First, the date is the authority: the flag and the date are not two independent switches but a flag plus a default that the date activates, and the runtime reports the default as became the default as of 2026-08-04 in its own words. Second, a project that keeps the old flag after bumping the date is not broken — it is noisy. The warning is informational, the worker answers, and the string repeats once per service on every boot, which is exactly the kind of line that trains a team to ignore startup logs. If a config still carries the flag only because the date was raised, removing the flag is a two-line change with no behavioural effect at this date.

One caveat worth stating plainly: “older date plus the flag” is not one fixed runtime. The probe below shows the same flag at three pre-2026-08-04 dates producing two different process objects — the Node.js built-in modules and the process shim are activated by different defaults. Measure the combination you actually ship before assuming what an old date gives you.

Reproduce the whole boundary with one script

The harness is a single bash file. It writes both worker variants and a Cap'n Proto config, starts workerd three times (old date static, new date static, old date dynamic), and prints the real client and server output for each case. Install the engine once with npm install workerd; the script looks for ./node_modules/.bin/workerd by default and honours a W= override:

#!/usr/bin/env bash
set -u
W=${W:-./node_modules/.bin/workerd}
PORT=${PORT:-19951}

write_case() {                 # $1 = compatibilityDate
  cat > worker-static.js <<'EOF'
import { Buffer } from "node:buffer";
export default {
  async fetch() {
    return new Response("node:buffer ok, hex=" + Buffer.from("c2cz").toString("hex") + "\n");
  },
};
EOF
  cat > worker-dyn.js <<'EOF'
export default {
  async fetch() {
    try {
      const { Buffer } = await import("node:buffer");
      return new Response("dynamic node:buffer ok, hex=" + Buffer.from("c2cz").toString("hex") + "\n");
    } catch (err) {
      return new Response("dynamic import failed: " + err.name + ": " + err.message + "\n", { status: 500 });
    }
  },
};
EOF
  cat > config.capnp <<EOF
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
  services = [ (name = "main", worker = (
    modules = [ (name = "worker.js", esModule = embed "MODULE") ],
    compatibilityDate = "$1",
  )) ],
  sockets = [ (name = "http", address = "127.0.0.1:$PORT", http = (), service = "main") ],
);
EOF
}

run_case() {                   # $1 = label $2 = date $3 = module
  write_case "$2"
  sed -i "s/embed \"MODULE\"/embed \"$3\"/" config.capnp
  echo "### $1: compatibilityDate = $2, module = $3"
  timeout 12 "$W" serve config.capnp > "log-$1.txt" 2>&1 &
  srv=$!
  sleep 2
  printf 'HTTP: '
  curl -sS --max-time 4 "http://127.0.0.1:$PORT/" 2>&1 || echo "(no HTTP answer)"
  kill "$srv" 2>/dev/null
  wait "$srv" 2>/dev/null
  echo "--- workerd log ---"
  cat "log-$1.txt"
  echo
}

run_case old-static  2026-08-03 worker-static.js
run_case new-static  2026-08-04 worker-static.js
run_case old-dynamic 2026-08-03 worker-dyn.js

Real output of that file, unedited. Note that the failing case prints the client error first and the cause second, which is the ordering that misleads people into debugging curl:

CaseClient outputServer log
old-staticcurl: (7) Failed to connect to 127.0.0.1 port 19951 after 0 ms: Could not connect to serverservice main: Uncaught Error: No such module "node:buffer".
imported from "worker.js"
new-staticnode:buffer ok, hex=6332637a(empty)
old-dynamicdynamic import failed: Error: No such module "node:buffer". (HTTP 500)(empty)

Two details make this a usable repro rather than a demo. First, the config is generated per case with the date interpolated, so the diff between the passing and failing runs is literally one string. Second, workerd is started with timeout and killed by PID, so a case that fails to boot cannot leave a process holding the port and silently break the next case — the failure mode that makes hand-run repros produce impossible results, such as a “new date” run that answers with the old case's response.

What is actually available on each side of the boundary

“Node.js compatibility” is not one switch, and the error message gives you no inventory. This probe reports what a worker can actually see. It is the configuration from fix 2 below — an older date kept deliberately, with the flag added — so it runs under the same harness with compatibilityDate = "2026-08-03" and compatibilityFlags = [ "nodejs_compat" ]:

export default {
  async fetch() {
    const { Buffer } = await import("node:buffer");
    const lines = [
      "globalThis.Buffer     : " + typeof globalThis.Buffer,
      "node:buffer hex       : " + Buffer.from("c2cz").toString("hex"),
      "typeof process        : " + typeof process,
      "process.versions.node : " + (process?.versions?.node ?? "(undefined)"),
      "import.meta.url       : " + String(import.meta.url),
    ];
    return new Response(lines.join("\n") + "\n");
  },
};

Measured output, workerd 2026-09-15:

globalThis.Buffer     : function
node:buffer hex       : 6332637a
typeof process        : object
process.versions.node : 22.19.0
import.meta.url       : undefined

Read that as a caution list rather than a compatibility matrix. Buffer works and is also exposed globally, so ordinary binary handling is fine. process exists and process.versions.node is populated at this date, so version-branching code does work here — but not at every date. I ran the identical probe, same flag, at three dates: 2024-09-25 and 2025-08-01 both return process.versions.node : (undefined), while 2026-08-03 returns 22.19.0. The boundary lies between those dates and I did not bisect it further, so treat “a version string is available” as date-dependent rather than as a property of the Node.js modules. If your code branches on process.versions.node, use an explicit ?? fallback as the probe does: an undefined value takes the falsy branch silently rather than throwing.

import.meta.url being undefined is the more expensive one, because the ecosystem's standard bridge from ESM to CommonJS APIs uses it. The measured failure, from a worker built on createRequire:

require err: TypeError: The argument 'path' The argument must be a file URL object, a file URL string, or an absolute path string.. Received 'undefined'

That message is about the argument, not about module compatibility, so it sends you looking for a path bug in your own code. The argument is import.meta.url, and it is undefined by design in this runtime: a worker module is not loaded from a filesystem URL. The fix is not to construct a fake URL. It is to use the ESM import you already have — await import("node:buffer") in place of require("buffer") — and to keep createRequire out of the bundle. If some dependency insists on calling require internally, that dependency is the thing to replace or vendor, because no date and no flag will give it an import.meta.url that means anything.

The three fixes, in the order worth trying

1. Raise the compatibility date and drop the flag. If the project's compatibility_date predates 2026-08-04 and the only reason the flag is present is the Node.js modules, moving the date forward is the whole fix. The measured effect is that node:buffer resolves with no flags at all, and the flag becomes a warning line if you leave it behind. Check what else the date gates before jumping: a compatibility date is a bundle of behaviour changes, so read the project's own test suite output rather than assuming this specific change is isolated. In this lab it was isolated, because the only variable was the module registry.

2. If the date must stay pinned, add nodejs_compat. That is the measured difference between the first two rows of the boundary table: same old date, same source, one flag, and the boot failure becomes a 200. This is the right choice when the date is pinned deliberately — a reproducibility requirement, a regulated deploy, or a codebase that has not absorbed the other changes in the newer date. Note the trade-off shown in the probe: import.meta.url is undefined at every date I tested, and process.versions.node is present only on the newer ones, so code probing either will behave differently from a modern Node.js process.

3. Remove the Node.js dependency from the Worker. Worth doing only when the module is being imported incidentally, and there is no quick rename available: in the failing configuration, import { Buffer } from "buffer" produces service main: Uncaught Error: No such module "buffer". and import { createHash } from "node:crypto" produces service main: Uncaught Error: No such module "node:crypto". Where the API is genuinely optional in your code, the dynamic import in the repro script is the shape to copy: it keeps the worker booting and converts a fatal resolution error into a 500 that names the missing module.

Where the date lives on the deploy side

Locally the value is whatever you put in your harness config; in a project it is the compatibility_date field. The flag is the part people forget to remove:

{
  "name": "c2cz-api",
  "main": "src/index.js",
  "compatibility_date": "2026-08-04",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true }
}

That configuration is complete but self-contradictory in one place: the date already turns the Node.js modules on, so the flag only produces the warning line measured above. Delete the compatibility_flags entry and keep the date. The inverse configuration — old date plus the flag — is the one that must be kept deliberately, and it deserves a comment in the repository explaining which Node.js module needs it, because the flag reads like legacy cruft to whoever finds it next.

The same field exists in the TOML form as compatibility_date = "..." and compatibility_flags = ["nodejs_compat"]. Both files feed the same value into the runtime; there is no separate switch. I have listed the deploy-side file as configuration rather than executing it here: this host runs the engine directly through workerd, with no project toolchain, so the local harness above is the executed artifact and the JSON is the field mapping to check against your own file.

Guard it in CI with one step

Because the failure is a boot failure, it is caught by a smoke test that boots the worker under its pinned date and inspects the log. This is the version I ran, first against a config with 2026-08-03 and then against the same config with 2026-08-04:

#!/usr/bin/env bash
# ci-check.sh — fail the build when the pinned compatibility date cannot resolve node: modules
set -u
LOG=$(mktemp)
timeout 15 ./node_modules/.bin/workerd serve config.capnp > "$LOG" 2>&1 &
srv=$!
sleep 3
rc=0
if grep -q 'No such module' "$LOG"; then
  echo "FAIL: module resolution failed under the pinned compatibilityDate"
  cat "$LOG"
  rc=1
elif curl -sf --max-time 4 "http://127.0.0.1:19951/" > /dev/null; then
  echo "OK: worker booted and answered"
else
  echo "FAIL: worker booted but never answered"
  rc=1
fi
kill "$srv" 2>/dev/null
wait "$srv" 2>/dev/null
rm -f "$LOG"
exit "$rc"

Measured, both branches, same script and same worker source:

# config.capnp with compatibilityDate = "2026-08-03"
FAIL: module resolution failed under the pinned compatibilityDate
service main: Uncaught Error: No such module "node:buffer".
  imported from "worker.js"
exit=1

# config.capnp with compatibilityDate = "2026-08-04"
OK: worker booted and answered
exit=0

The grep -q 'No such module' check is deliberately textual. Matching the runtime's own resolution message catches every built-in at once — node:buffer, node:crypto, the bare buffer form — without enumerating them, and it stays valid when a dependency adds a new import six months from now. The second branch matters too: a worker that boots but never answers is a different bug, and the check refuses to call it a pass.

Triage table: which string means which layer

What you seeWhere it comes fromNext step
service main: Uncaught Error: No such module "node:buffer".Module resolution, at bootCheck the pinned compatibility_date against 2026-08-04; the date is the primary suspect, not the import
No such module "buffer". / No such module "node:crypto".Same registry, other specifierAdding a flag is the fix; renaming the specifier is not
The compatibility flag nodejs_compat became the default as of 2026-08-04 so does not need to be specified anymore.Config lint, informationalRemove the redundant flag; the worker is fine
dynamic import failed: Error: No such module "node:buffer". at HTTP 500Your handler, after a successful bootSame root cause, different surface — the boot log is clean, so look at the import site
TypeError: The argument 'path' The argument must be a file URL object, ... Received 'undefined'createRequire(import.meta.url)import.meta.url is undefined in a worker module; switch to a real import
curl: (7) Failed to connect immediately after starting the dev serverClientThe process died before binding; read the server log first. If you have already met the other Workers boot errors — a handler that exports no fetch() or a handler that hangs → Error 1101 — this one is settled at config load instead

What this run does and does not prove

Proven here, by execution: the exact error strings for static and dynamic imports of node:buffer, the equivalent failures for the bare buffer specifier and for node:crypto, the day-level boundary at 2026-08-04 on workerd 2026-09-15, the informational warning when the flag outlives its purpose, the runtime inventory across three dates (Buffer present everywhere; import.meta.url undefined everywhere; process.versions.node undefined at 2024-09-25 and 2025-08-01, 22.19.0 at 2026-08-03), and a CI check that returns 1 and 0 on the two branches.

Not proven here: the deploy-time wording on Cloudflare's side. This host runs the engine directly, so no upload step was exercised, and I am not asserting what a failed deploy prints. The cause is the same one — resolution happens against the pinned date — which is why the local reproduction is a valid diagnosis of a production symptom, and why the CI step above is the version I would add to a pipeline: it tests the behaviour under the project's own pinned date rather than against a date the lab chose. If you are comparing against a production incident, also check that the deployed config really carries the date you think it does; the entire failure mode here is one string being older than a reader assumes.

Two related Workers failures worth reading next: Body has already been used, where the same request object is consumed twice, and the Workers boot-error family above. Both are runtime errors you can catch; this one is not, which is the whole reason it deserves a CI check instead of a handler.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.