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

Disallowed operation called within global scope: the fix

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

Disallowed operation called within global scope is what the Workers runtime throws when module-scope code starts asynchronous I/O, sets a timer, or generates a random value — and it does not throw it from your handler: it throws it while loading your Worker, before any request exists. One crypto.randomUUID() or setTimeout() above your export default is enough to take the whole service down: the runtime logs Uncaught Error: Disallowed operation called within global scope…, never binds its socket, and every request dies before a handler runs. The same rule has two quieter failure modes that are worse than the crash — a Date.now() at module scope that returns 0 with no error at all, and an unawaited fetch() whose rejection nobody notices until the cache it filled is read by every subsequent request.

Every string quoted below was executed on this host — arm64 Linux 6.17, Node v26.5.1, the real Workers runtime workerd 2026-09-15 with compatibilityDate = "2026-09-01" — by the code shown, in the order shown. The harness starts one runtime process per case so a log line can never be attributed to the wrong module, and it was run twice; the two outputs were identical. Platform-level claims (the error-code family, what Cloudflare itself says about the top-level scope) are quoted from Cloudflare’s Errors and exceptions reference and labelled as such.

TL;DR — which branch are you in?

  • Four shapes are fatal at load time, and all four are one line. await fetch(…), setTimeout(…), crypto.randomUUID() and crypto.getRandomValues(…) at module scope each produce the same Uncaught Error and the same dead service. getRandomValues is the surprise: it is synchronous, and it is still refused, so the runtime gates the API, not the promise.
  • The failure is at load, not at request. The socket is never bound: curl reports 000 on every attempt. Cloudflare documents this class as a startup failure — the 10021 family, which covers “attempts to load and run the top-level scope… before your Worker’s handler is invoked”.
  • Two quieter shapes load fine and serve 200, which is worse. A bare fetch(…) at module scope whose rejection is unhandled loads with an empty log. An unawaited async IIFE with a try/catch stores that error into a variable and reports it as clean data.
  • The clock is the nastiest one. Date.now() at module scope returns 0; new Date() returns 1970-01-01T00:00:00.000Z. No error, no log line — a constant that every request will happily use for a token expiry, a cache key or a TTL.
  • The rule is narrower than “no work at module scope”. await crypto.subtle.digest(…), await Promise.resolve(42) and Math.random() at module scope all work. Only asynchronous I/O, timers and crypto random values are refused.
  • Bindings are not globals. Referencing GREETING or env at module scope is Uncaught ReferenceError: GREETING is not defined — also a startup failure, also the whole service. Bindings arrive as the handler’s second argument.
  • A memoised promise can poison every later request. One rejection is replayed for the lifetime of the isolate: measured 502 on request 1 and request 2. Clear it in a .catch().

What the message means, and what it is not

The runtime’s copy is unambiguous about the fix, and its own link points at the handlers page rather than at a configuration flag: “To fix this error, perform this operation within a handler”. That is the whole remedy. There is no compatibility flag, no binding and no build setting that makes module-scope I/O legal again — the isolation boundary that gives Workers their startup guarantees is exactly what the check protects.

On the platform side this surfaces as a deploy or startup failure rather than a per-request error. Cloudflare’s reference puts it in the 10021 family:

The 10021 error code includes all errors that occur when you attempt to deploy a Worker, and Cloudflare then attempts to load and run the top-level scope (everything that happens before your Worker’s handler is invoked).

— Cloudflare, Errors and exceptions

That sentence explains why the symptom looks the way it does in every environment. Locally under wrangler dev you get a Worker that starts and answers nothing. Deployed, you get a version that will not serve traffic. And in the local workerd lab below you get a service that logs the exception and never binds, which is the same event with less tooling on top of it.

It is worth separating this error from its two famous neighbours, because all three are routinely filed under “Workers I/O” and they need different fixes:

MessageWhen it firesWhat it means
Disallowed operation called within global scope…At load, before any requestModule-scope code touched asynchronous I/O, a timer, or crypto random values. Fatal for the whole service; nothing can be caught by the handler.
Cannot perform I/O on behalf of a different request…During a request, across two requestsA live I/O object was cached at module scope and dereferenced by a later request. Measured in detail here, including the Durable Object wording.
The script will never generate a response (1101)After the handler has runNo usable Response was produced. The four branches behind that label are here.

The distinction that matters in practice: this article’s error is the only one of the three you cannot catch, log and recover from inside your code. By the time a handler exists, the decision has already been made.

Reproduce every branch in one script

The harness below builds fifteen modules, runs each in its own workerd process on its own port, makes one request, kills the process and classifies it. One process per case is deliberate: with a single long-lived server, a module-scope crash and a request-time crash land in the same log and the attribution becomes guesswork.

Two details are worth reading before you run it. First, WORKERD defaults to the binary in this host’s lab tree and falls back to $PWD/node_modules/.bin/workerd, which is where npm install workerd puts it. Second, the verdict strings are computed inside the block by grepping the case’s own log, which is why the matrix can be reproduced line for line; the full message is printed verbatim in the next section.

mkdir -p /tmp/scope-lab && cd /tmp/scope-lab
WORKERD="${WORKERD:-/opt/data/c2cz/lab/20260915-hang/node_modules/.bin/workerd}"
[ -x "$WORKERD" ] || WORKERD="$PWD/node_modules/.bin/workerd"   # npm install workerd

cat > config.tmpl <<'CAPNP'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
  services = [
    (name = "main", worker = (
      modules = [ (name = "worker.js", esModule = embed "worker.js") ],
      bindings = [ (name = "GREETING", text = "hello") ],
      compatibilityDate = "2026-09-01",
    )),
    (name = "origin", worker = (
      modules = [ (name = "origin.js", esModule = embed "origin.js") ],
      compatibilityDate = "2026-09-01",
    )),
  ],
  sockets = [ (name = "http", address = "127.0.0.1:@PORT@", http = (), service = "main") ],
);
CAPNP

printf 'export default { async fetch() { return new Response("ok-from-origin"); } };\n' > origin.js

run_case () {                       # run_case <name> <port> <module source>
  name="$1"; port="$2"; d="/tmp/scope-lab/$name"
  mkdir -p "$d" && printf '%s\n' "$3" > "$d/worker.js"
  cp /tmp/scope-lab/origin.js "$d/origin.js"
  sed "s/@PORT@/$port/" /tmp/scope-lab/config.tmpl > "$d/config.capnp"
  "$WORKERD" serve "$d/config.capnp" > "$d/log.txt" 2>&1 &
  srv=$!; code=000
  for i in 1 2 3 4 5 6 7 8 9 10; do
    code=$(curl -s -o "$d/body.txt" -w '%{http_code}' --max-time 1 "http://127.0.0.1:$port/" 2>/dev/null)
    [ "$code" != "000" ] && break
    kill -0 $srv 2>/dev/null || break
    sleep 0.2
  done
  kill -9 $srv 2>/dev/null; wait $srv 2>/dev/null
  if grep -q 'Disallowed operation called within global scope' "$d/log.txt"; then
    verdict="FATAL at startup (curl 000, socket never bound)"
  elif grep -q 'ReferenceError' "$d/log.txt"; then
    verdict="FATAL at startup (ReferenceError, curl 000)"
  elif [ -s "$d/log.txt" ]; then
    verdict="FAILED: $(head -n1 "$d/log.txt" | cut -c1-60)"
  else
    verdict="loads; body=$(cat "$d/body.txt" 2>/dev/null)"
  fi
  printf '%-22s %-4s %s\n' "$name" "$code" "$verdict"
}

run_case2 () {                      # same, but two requests, printed side by side
  name="$1"; port="$2"; d="/tmp/scope-lab/$name"
  mkdir -p "$d" && printf '%s\n' "$3" > "$d/worker.js"
  cp /tmp/scope-lab/origin.js "$d/origin.js"
  sed "s/@PORT@/$port/" /tmp/scope-lab/config.tmpl > "$d/config.capnp"
  "$WORKERD" serve "$d/config.capnp" > "$d/log.txt" 2>&1 &
  srv=$!
  for i in 1 2 3 4 5 6 7 8 9 10; do
    code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "http://127.0.0.1:$port/" 2>/dev/null)
    [ "$code" != "000" ] && break
    kill -0 $srv 2>/dev/null || break
    sleep 0.2
  done
  r1=$(curl -s --max-time 2 "http://127.0.0.1:$port/" 2>/dev/null)
  r2=$(curl -s --max-time 2 "http://127.0.0.1:$port/" 2>/dev/null)
  kill -9 $srv 2>/dev/null; wait $srv 2>/dev/null
  printf '%-22s %-4s req1=%s | req2=%s\n' "$name" "$code" "$r1" "$r2"
}

p=19976
run_case top_await_fetch  $((p+0))  'const r = await fetch("https://example.com/");
export default { async fetch() { return new Response("handler"); } };'
run_case bare_fetch       $((p+1))  'fetch("https://example.com/").catch(() => {});
export default { async fetch() { return new Response("handler"); } };'
run_case top_settimeout   $((p+2))  'const t = setTimeout(() => {}, 1000);
export default { async fetch() { return new Response("handler " + t); } };'
run_case top_randomuuid   $((p+3))  'const id = crypto.randomUUID();
export default { async fetch() { return new Response("id=" + id); } };'
run_case top_getrandomvalues $((p+4)) 'const b = crypto.getRandomValues(new Uint8Array(4));
export default { async fetch() { return new Response("b=" + b.join(",")); } };'
run_case top_datenow      $((p+5))  'const t0 = Date.now(); const iso = new Date().toISOString();
export default { async fetch() { return new Response("Date.now()=" + t0 + " new Date()=" + iso); } };'
run_case top_mathrandom   $((p+6))  'const r = Math.random();
export default { async fetch() { return new Response("r-in-range=" + (r >= 0 && r < 1)); } };'
run_case top_subtle       $((p+7))  'const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode("abc"));
export default { async fetch() { return new Response("digest-bytes=" + h.byteLength); } };'
run_case top_await_plain  $((p+8))  'const v = await Promise.resolve(42);
export default { async fetch() { return new Response("v=" + v); } };'
run_case iife_unawaited   $((p+9))  'let cached = null;
(async () => { try { cached = await (await fetch("https://example.com/")).text(); }
               catch (e) { cached = "error:" + e.message.slice(0, 12); } })();
export default { async fetch() { return new Response("cached=" + cached); } };'
run_case handler_timers   $((p+10)) 'export default { async fetch() {
  await new Promise((r) => setTimeout(r, 10));
  return new Response("uuid-length=" + crypto.randomUUID().length + " timer=ok"); } };'
run_case lazy_memo_fix    $((p+11)) 'let ready = null;
function getOrigin() {
  if (ready === null) ready = (async () => "origin-status:" + (await fetch("https://example.com/")).status)();
  return ready;
}
export default { async fetch() { return new Response(await getOrigin()); } };'
run_case2 memo_poisoned   $((p+12)) 'let ready = null;
function getClient() {
  if (ready === null) ready = (async () => { throw new Error("origin refused"); })();
  return ready;
}
export default { async fetch() {
  try { return new Response(await getClient()); }
  catch (e) { return new Response("caught: " + e.message, { status: 502 }); } } };'
run_case module_binding_ref $((p+13)) 'const g = GREETING;                      // bindings are not globals
export default { async fetch() { return new Response("GREETING=" + g); } };'
run_case module_env_ref     $((p+14)) 'const r = await env.ORIGIN.fetch("http://origin/");
export default { async fetch() { return new Response(await r.text()); } };'

Output, unedited (two consecutive runs of this block were byte-identical):

top_await_fetch        000  FATAL at startup (curl 000, socket never bound)
bare_fetch             200  loads; body=handler
top_settimeout         000  FATAL at startup (curl 000, socket never bound)
top_randomuuid         000  FATAL at startup (curl 000, socket never bound)
top_getrandomvalues    000  FATAL at startup (curl 000, socket never bound)
top_datenow            200  loads; body=Date.now()=0 new Date()=1970-01-01T00:00:00.000Z
top_mathrandom         200  loads; body=r-in-range=true
top_subtle             200  loads; body=digest-bytes=32
top_await_plain        200  loads; body=v=42
iife_unawaited         200  loads; body=cached=error:Disallowed o
handler_timers         200  loads; body=uuid-length=36 timer=ok
lazy_memo_fix          200  loads; body=origin-status:200
memo_poisoned          502  req1=caught: origin refused | req2=caught: origin refused
module_binding_ref     000  FATAL at startup (ReferenceError, curl 000)
module_env_ref         000  FATAL at startup (ReferenceError, curl 000)

Read the matrix in three groups. Lines 1–5 and 14–15 are fatal at load: no socket, no handler, no recovery. Lines 6–11 load and serve 200, but three of them are lying to you. Lines 12–13 are the two shapes that behave the way you want — a lazily-initialised memo and a handler that uses the timer and the crypto module.

The fatal branches: one line at module scope is enough

The matrix hands you a verdict, not the message. This block prints the message as the runtime emits it, for a single offending line, with no filtering:

mkdir -p /tmp/scope-lab-msg && cd /tmp/scope-lab-msg
WORKERD="${WORKERD:-/opt/data/c2cz/lab/20260915-hang/node_modules/.bin/workerd}"
[ -x "$WORKERD" ] || WORKERD="$PWD/node_modules/.bin/workerd"

cat > worker.js <<'JS'
const id = crypto.randomUUID();          // one line at module scope is enough
export default { async fetch() { return new Response("id=" + id); } };
JS

cat > config.capnp <<'CAPNP'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
  services = [ (name = "main", worker = (
    modules = [ (name = "worker.js", esModule = embed "worker.js") ],
    compatibilityDate = "2026-09-01",
  )) ],
  sockets = [ (name = "http", address = "127.0.0.1:19990", http = (), service = "main") ],
);
CAPNP

"$WORKERD" serve config.capnp > log.txt 2>&1 &
srv=$!
for i in 1 2 3 4 5 6 7 8 9 10; do
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 http://127.0.0.1:19990/ 2>/dev/null)
  [ "$code" != "000" ] && break
  sleep 0.2
done
kill -9 $srv 2>/dev/null; wait $srv 2>/dev/null
echo "curl http_code=$code"
cat log.txt

Output:

curl http_code=000
service main: Uncaught Error: Disallowed operation called within global scope. Asynchronous I/O (ex: fetch() or connect()), setting a timeout, and generating random values are not allowed within global scope. To fix this error, perform this operation within a handler. https://developers.cloudflare.com/workers/runtime-apis/handlers/
  at worker.js:1:19

Three things in that output are load-bearing.

The 000. curl never gets an HTTP status, because the service never binds a socket. Every request-level theory you might have — middleware, routing, a bad env, a failed database connection — is ruled out by that one zero. Nothing is being served at all.

The prefix service main:. That is the service name from the Cap’n Proto config in the block, and it is the runtime telling you which module failed to evaluate. In a multi-service config it is how you tell a broken origin service from a broken frontend without reading stack traces.

The frame at worker.js:1:19. Column 19 is the position of crypto.randomUUID() on line 1, which is where the runtime refused. This frame is the only part of the message you cannot get from a search engine, and it is the part that tells you which of four or five candidate lines to move.

The four fatal shapes measured by the matrix are worth grouping, because they are not obviously one family until you see the list:

  • await fetch(…) — asynchronous I/O, and the case everyone eventually writes when they try to warm a cache.
  • setTimeout(…) — a timer. The stack goes through node-internal:internal_timers:57:15 in #constructTimer, which is the nodejs_compat shim: turning on Node compatibility does not exempt the Node timer API from the scope rule.
  • crypto.randomUUID() — generating random values.
  • crypto.getRandomValues(new Uint8Array(4)) — also generating random values, and this one is synchronous. The check therefore cannot be “does this call return a promise”; it is bound to the API surface.

The last two lines of the matrix, module_binding_ref and module_env_ref, fail with a different exception and the same consequence. GREETING is declared as a text binding in the config, and reading it at module scope gives Uncaught ReferenceError: GREETING is not defined; the same holds for env. Bindings are per-request values, not globals, so the pattern “grab my secrets at the top of the file” fails here before it can fail anywhere else. The handler’s second argument is the only place they exist.

The silent branch: the clock reads zero at module scope

Two cases in the matrix return 200 and are still wrong. The top_datenow row answers Date.now()=0 new Date()=1970-01-01T00:00:00.000Z.

No exception, no log line, no warning. Outside a request the runtime hands out the epoch, and a module-scope timestamp therefore evaluates to exactly zero — and to the same zero on every isolate, in every region, forever. Note that top_mathrandom in the same matrix returns r-in-range=true: Math.random() is not gated, so the module does not fail, it just becomes deterministic in the one place you wanted entropy and frozen in the one place you wanted time.

This is the branch that costs money. A timestamp captured once at startup and reused can end up in:

  • a signed request header or a JWT exp computed from a module-scope const now — every token minted with an expiry derived from 0;
  • a cache key or an ETag seed — identical across deploys, so stale responses look fresh;
  • a TTL or a backoff deadline — a deadline that has always already passed, or one that never arrives.

And it is invisible to every triage tool you would normally reach for. There is no exception to grep, no 5xx, no failing test on a mocked clock — because a mock replaces the very call that is broken. The only reliable signal is that the value is exactly 0 when you print it from a request.

The swallowed branch: a rejection nobody is watching

Now the two rows that are arguably worse than the crash. bare_fetch puts a fetch(…).catch(() => {}) at module scope: the service loads, the handler answers 200 with handler, and the log is empty — the rejection had a handler, so nothing was reported, but the operation itself was still refused. iife_unawaited is the production version of the same mistake — an async IIFE wrapped in try/catch that writes the outcome into a module-scope variable. The matrix reports it as iife_unawaited 200 loads; body=cached=error:Disallowed o; the harness truncates that body at forty characters, which is enough to see the shape. The module’s cache now contains the error text of the disallowed call, and every request will serve from it. Nothing threw, nothing logged, and the health check is green.

The same trap reappears in the fix itself, which is the part worth internalising. Memoising a promise is the correct way to do lazy initialisation in a Worker, because every request on the same isolate then shares one connection attempt instead of opening one per request. But the memo has no expiry and no reset: if that first attempt fails, the rejection is cached and replayed. The matrix measures it directly — memo_poisoned answers 502 req1=caught: origin refused | req2=caught: origin refused, so the second request, arriving later against a healthy origin, is served the first request’s failure.

A one-line .catch() that nulls the memo turns a permanently broken isolate back into one that retries on the next request. That line is in the fix block below, and it is the single highest-value line in this article.

Three things that are not the problem

Before you restructure anything, rule these out, because the error’s wording invites over-correction. Every one of them was measured at module scope in the same harness and every one of them is fine:

  • await crypto.subtle.digest("SHA-256", …) → digest-bytes=32. Asynchronous, cryptographic, and allowed.
  • await Promise.resolve(42) → v=42. Top-level await itself is not the problem; what you await is.
  • Math.random() → r-in-range=true. Randomness from Math is allowed; randomness from crypto is not.

So the rule is not “do nothing at module scope” and it is not “no await at module scope”. It is specifically: no asynchronous I/O, no timers, no crypto random values. Which means the correction for a Worker that trips this error is almost always a move, not a rewrite — the objects you were building stay exactly as they are, and the line that starts them moves inside the handler.

The fix: declarations at module scope, work in the handler

The rewrite below is the pattern to copy. Module scope holds one null and one function; the first request does the I/O, memoises the promise, and clears the memo if the promise rejects. The clock is read inside the request, so openedAt is a real timestamp rather than 0:

mkdir -p /tmp/scope-fix && cd /tmp/scope-fix
WORKERD="${WORKERD:-/opt/data/c2cz/lab/20260915-hang/node_modules/.bin/workerd}"
[ -x "$WORKERD" ] || WORKERD="$PWD/node_modules/.bin/workerd"

cat > origin.js <<'JS'
export default { async fetch() { return new Response("ok-from-origin"); } };
JS

cat > worker.js <<'JS'
let client = null;                       // module scope: a declaration, nothing else

function getClient(env) {                // first touch happens inside a request
  if (client === null) {
    client = (async () => {
      const res = await env.ORIGIN.fetch("http://origin/");
      return { status: res.status, openedAt: Date.now() };   // clock read in a request
    })();
    client.catch(() => { client = null; });                  // never leave a rejected memo behind
  }
  return client;
}

export default {
  async fetch(request, env, ctx) {
    try {
      const c = await getClient(env);
      return new Response("origin-status=" + c.status + " opened-in-request=" + (c.openedAt > 0));
    } catch (e) {
      return new Response("client-unavailable", { status: 502 });
    }
  },
};
JS

cat > config.capnp <<'CAPNP'
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
  services = [
    (name = "main", worker = (
      modules = [ (name = "worker.js", esModule = embed "worker.js") ],
      bindings = [ (name = "ORIGIN", service = (name = "origin")) ],
      compatibilityDate = "2026-09-01",
    )),
    (name = "origin", worker = (
      modules = [ (name = "origin.js", esModule = embed "origin.js") ],
      compatibilityDate = "2026-09-01",
    )),
  ],
  sockets = [ (name = "http", address = "127.0.0.1:19977", http = (), service = "main") ],
);
CAPNP

"$WORKERD" serve config.capnp > log.txt 2>&1 &
srv=$!; code=000
for i in 1 2 3 4 5 6 7 8 9 10; do
  code=$(curl -s -o body.txt -w '%{http_code}' --max-time 1 http://127.0.0.1:19977/ 2>/dev/null)
  [ "$code" != "000" ] && break
  kill -0 $srv 2>/dev/null || break
  sleep 0.2
done
kill -9 $srv 2>/dev/null; wait $srv 2>/dev/null
echo "curl http_code=$code body=$(cat body.txt 2>/dev/null)"
echo "log lines: $(wc -l < log.txt)"

Output:

curl http_code=200 body=origin-status=200 opened-in-request=true
log lines: 0

http_code=200 with opened-in-request=true is the whole point: the value that used to be a silent zero is now a plausible timestamp, and the service starts. log lines: 0 is the second half of the claim — a correct module produces no startup noise at all, so any line in a Worker’s startup log is worth reading.

The four rules that block encodes, in the order they bite:

  1. Declarations only at module scope. let/const holding null, plain functions, classes, type definitions. No calls that start work.
  2. Take bindings from the handler argument. fetch(request, env, ctx). There is no module-scope env; if you need the value elsewhere, pass it in as a parameter, as the block does.
  3. Memoise the promise, not the client. Assign the promise to the module-scope variable so concurrent requests share one attempt instead of racing to open several.
  4. Clear a rejected memo. memo.catch(() => { memo = null; }). Without it, the first failure becomes the isolate’s permanent state.

Note that ctx.waitUntil() is not an escape hatch for any of this. It defers and extends work that was started inside a handler; it cannot legalise a call made before the handler exists.

The client pattern that keeps the bug out

Applied to the case this desk writes about most often — a libSQL/Turso client driven over HTTP fetch() from a Worker — the corrected module looks like this. It is a listing rather than a transcript: nothing is quoted from a run, and the comments name the exact lines that would have failed.

// libSQL / Turso over HTTP fetch: the shape that survives module-scope rules.
// Wrong: the client is built, a token is signed and a clock is read at load time
//   const client = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN });
//   const bootedAt = Date.now();                       // 0, silently
//   const session  = await client.execute("SELECT 1"); // Disallowed operation ..., fatal
//
// Right: module scope declares, the first request does the work.

let clientPromise = null;            // memoised promise, never the client itself

function httpEndpoint(dbUrl) {
  // env.TURSO_URL arrives as turso:// or libsql://, and fetch() only speaks http(s):
  //   fetch("libsql://db-org.turso.io/v2/pipeline")
  //   -> TypeError: Fetch API cannot load: libsql://db-org.turso.io/v2/pipeline
  return `${dbUrl.replace(/^(turso|libsql):\/\//, "https://")}/v2/pipeline`;
}

function getClient(env) {
  if (clientPromise === null) {
    clientPromise = (async () => {
      const url = httpEndpoint(env.TURSO_URL);        // libSQL SQL-over-HTTP endpoint
      const probe = await fetch(url, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${env.TURSO_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          // End the pipeline with a close, or the connection is held until it times
          // out: Turso's reference notes connections are "left open until they timeout".
          requests: [
            { type: "execute", stmt: { sql: "SELECT 1" } },
            { type: "close" },
          ],
        }),
      });
      if (!probe.ok) throw new Error(`turso probe failed: HTTP ${probe.status}`);
      return { endpoint: url, openedAt: Date.now() };  // clock read inside a request
    })();
    // A rejected memo would be replayed to every later request: clear it.
    clientPromise.catch(() => { clientPromise = null; });
  }
  return clientPromise;
}

export default {
  async fetch(request, env, ctx) {
    try {
      const client = await getClient(env);
      return Response.json({ ok: true, openedAt: client.openedAt });
    } catch (err) {
      return Response.json({ ok: false, error: String(err.message) }, { status: 502 });
    }
  },
};

Two details in that listing are worth pulling out, because both bite at runtime rather than at review. First, env.TURSO_URL is a turso:// or libsql:// URL and fetch() only speaks http(s): measured on workerd 2026-09-15, fetch("libsql://db-org.turso.io/v2/pipeline") rejects with TypeError: Fetch API cannot load: libsql://db-org.turso.io/v2/pipeline, so the protocol has to be rewritten before the call — Turso’s SQL-over-HTTP reference says the same thing in one line: “Simply replace your database URL protocol (turso:// for Turso databases, libsql:// for libSQL databases) with https://”. Second, a pipeline with no closing operation leaves the connection open — same reference: “Connections are left open until they timeout, unless you close them explicitly in the request” — which is why the body ends with { "type": "close" }. Neither point is about module scope, and both are the kind of thing that turns a passing local probe into a leak in production.

The three errors this shape prevents are the three the matrix measured. The client is never constructed at load, so no fetch() starts before a request exists. The token and the URL come from env as a parameter rather than as a global, so the ReferenceError branch is unreachable. And openedAt is read from the clock inside the request, so it is a timestamp rather than 0.

One caveat worth stating plainly: a memoised promise gives you connection reuse per isolate, not a pool. Isolates come and go, so treat the memo as a startup-cost optimisation and never as state you depend on. Anything that must survive the isolate belongs in D1, KV or a Durable Object, not in a module-scope variable.

Triage checklist

Run these in order. The first two lines alone resolve most reports of this error.

  1. Confirm it is a load failure, not a request failure. If your local Worker answers nothing and the log’s first line contains Uncaught Error: Disallowed operation called within global scope, the rest of the checklist is about finding the line; every other theory is dead.
  2. Take the file, line and column from the stack frame (at worker.js:1:19 in the examples above) and look at that column rather than the whole line — the frame points at the exact call the runtime refused.
  3. Search module scope for the four fatal shapes: fetch(, connect(, setTimeout(, setInterval(, randomUUID(, getRandomValues(. Include the ones wrapped in try/catch or .catch() — those are the silent branch, not the safe one.
  4. Then search for the two silent shapes in the same region: Date.now() and new Date(). They will not appear in any log; they only appear in wrong values. Print the value from inside a request to confirm.
  5. Check for module-scope binding access. Bare identifiers matching binding names, or env., above the handler are ReferenceErrors waiting to happen, and they are equally fatal.
  6. Audit every memo for a reset path. Each module-scope promise needs a .catch() that nulls it, or the first failure is permanent for that isolate.
  7. Re-run the load, not just the request. A Worker that serves 200 from a warm local process can still fail to start from cold; restart the runtime after the edit rather than reloading the page.

Sources and related work

C2CZ

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