Disallowed operation called within global scope: the fix
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()andcrypto.getRandomValues(…)at module scope each produce the sameUncaught Errorand the same dead service.getRandomValuesis 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:
curlreports000on 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 barefetch(…)at module scope whose rejection is unhandled loads with an empty log. An unawaitedasyncIIFE with atry/catchstores that error into a variable and reports it as clean data. - The clock is the nastiest one.
Date.now()at module scope returns0;new Date()returns1970-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)andMath.random()at module scope all work. Only asynchronous I/O, timers and crypto random values are refused. - Bindings are not globals. Referencing
GREETINGorenvat module scope isUncaught 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
502on 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:
| Message | When it fires | What it means |
|---|---|---|
Disallowed operation called within global scope… | At load, before any request | Module-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 requests | A 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 run | No 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 throughnode-internal:internal_timers:57:15 in #constructTimer, which is thenodejs_compatshim: 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
expcomputed from a module-scopeconst now— every token minted with an expiry derived from0; - 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-levelawaititself is not the problem; what you await is.Math.random()→r-in-range=true. Randomness fromMathis allowed; randomness fromcryptois 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:
- Declarations only at module scope.
let/constholdingnull, plain functions, classes, type definitions. No calls that start work. - Take bindings from the handler argument.
fetch(request, env, ctx). There is no module-scopeenv; if you need the value elsewhere, pass it in as a parameter, as the block does. - 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.
- 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.
- 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. - Take the file, line and column from the stack frame (
at worker.js:1:19in the examples above) and look at that column rather than the whole line — the frame points at the exact call the runtime refused. - Search module scope for the four fatal shapes:
fetch(,connect(,setTimeout(,setInterval(,randomUUID(,getRandomValues(. Include the ones wrapped intry/catchor.catch()— those are the silent branch, not the safe one. - Then search for the two silent shapes in the same region:
Date.now()andnew Date(). They will not appear in any log; they only appear in wrong values. Print the value from inside a request to confirm. - Check for module-scope binding access. Bare identifiers matching binding names, or
env., above the handler areReferenceErrors waiting to happen, and they are equally fatal. - 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. - Re-run the load, not just the request. A Worker that serves
200from 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
- Cloudflare, Errors and exceptions — the 10021 family and the “load and run the top-level scope” wording quoted above, plus the neighbouring global-scope I/O case. developers.cloudflare.com/workers/observability/errors/
- Cloudflare, Handlers — the page the runtime’s own error message links to, and where
fetch(request, env, ctx)is specified. developers.cloudflare.com/workers/runtime-apis/handlers/ - Cannot perform I/O on behalf of a different request — the cross-request sibling, with the Durable Object wording and the
waitUntildistinction. - The script will never generate a response — the four branches behind error 1101, including the wrong-type return that is often mistaken for a hang.
- The Worker’s code had hung — the cached-promise hang, and why caching a promise is both the fix and the trap.
- Workers EvalError: code generation from strings disallowed — the other runtime restriction that fires before your code decides anything.