SSR Bindings via AsyncLocalStorage
How the zfb Cloudflare adapter threads per-request env/ctx into SSR pages using AsyncLocalStorage and an advanced-mode _worker.js
Overview
When an SSR framework builds for Cloudflare Pages, it has to solve one
awkward problem: a page handler that runs deep inside the framework’s
router needs access to the per-request env (your KV, D1, R2, and secret
bindings) and ctx (waitUntil, passThroughOnException). Those values
are only handed to the top-level fetch(request, env, ctx) entry — they
are not global, and passing them down through every layer of the router by
hand is impractical.
The @takazudo/zfb-adapter-cloudflare adapter solves this with
AsyncLocalStorage. It emits a Cloudflare Pages advanced-mode
_worker.js that wraps the real (inner) worker bundle, captures
{ env, ctx, request } at the entry point, and makes it readable anywhere
in the request via a small getCloudflareContext<Env>() accessor.
This page explains how that wrapper works, why AsyncLocalStorage (rather
than a plain global) is the correct mechanism, and the advanced-mode
contract you inherit the moment a _worker.js exists.
What the adapter emits
zfb build produces two files for Cloudflare Pages advanced mode:
dist/_worker.js— the generated wrapper (shown below).dist/_zfb_inner.mjs— your actual SSR bundle (the framework router and all your pages).
The wrapper imports the inner bundle by relative path
(./_zfb_inner.mjs) rather than inlining it. Workerd’s module loader
resolves relative ESM imports inside an advanced-mode _worker.js
directory, so the two files ship side by side and the adapter package
never needs to bundle an esbuild binary into your output.
The generated _worker.js wrapper
// AUTO-GENERATED by @takazudo/zfb-adapter-cloudflare. Do not edit.
//
// Cloudflare Pages advanced mode entry. Forwards (request, env, ctx) to
// the inner zfb worker bundle, exposing env/ctx to user code via
// AsyncLocalStorage under a stable globalThis key.
import { AsyncLocalStorage } from "node:async_hooks";
import inner from "./_zfb_inner.mjs";
const STORAGE_KEY = "__zfb_cf_adapter_als__";
function getStorage() {
const g = globalThis;
let als = g[STORAGE_KEY];
if (!als) {
als = new AsyncLocalStorage();
g[STORAGE_KEY] = als;
}
return als;
}
function canDelegateToAssets(env) {
return Boolean(env && env.ASSETS && typeof env.ASSETS.fetch === "function");
}
function isAssetProbeMethod(method) {
// Only safe, side-effect-free methods probe the asset server. POST/
// PUT/PATCH/DELETE go straight to the inner SSR worker.
return method === "GET" || method === "HEAD";
}
export default {
async fetch(request, env, ctx) {
if (isAssetProbeMethod(request.method) && canDelegateToAssets(env)) {
const assetResponse = await env.ASSETS.fetch(request);
if (assetResponse.status !== 404) {
return assetResponse;
}
// Fall through to the inner worker for genuinely dynamic routes.
}
const store = { env, ctx, request };
return getStorage().run(store, () => inner.fetch(request));
},
};
Two things are happening here, and they are independent: a storage
registry (getStorage + als.run) and a dispatch policy
(isAssetProbeMethod + canDelegateToAssets). The next two sections take
them one at a time.
Reading bindings from a page
Inside any SSR page, you read the captured context through the adapter’s accessor:
// pages/api/products.tsx
import { getCloudflareContext } from "@takazudo/zfb-adapter-cloudflare";
export const prerender = false; // opt out of build-time SSG
interface Env {
ANTHROPIC_API_KEY: string;
DB: D1Database; // a `wrangler.toml` D1 binding named "DB"
}
export default async function Products() {
const { env, ctx } = getCloudflareContext<Env>();
ctx.waitUntil(reportToAnalytics());
// A D1 binding is just-another-object on `env` — query it directly.
const { results } = await env.DB.prepare("SELECT * FROM products").all();
return new Response(JSON.stringify(results), {
headers: { "content-type": "application/json" },
});
}
getCloudflareContext() simply reads the active AsyncLocalStorage store
that the wrapper opened with als.run. The <Env> generic is type-only —
it narrows env to your bindings (here, a DB: D1Database), but the
runtime value is the exact env object Cloudflare passed into the
wrapper’s fetch. The adapter never inspects env’s members, so env.DB,
env.ANTHROPIC_API_KEY, a KV namespace, an R2 bucket — all are threaded
through verbatim. A D1 database is just another object on env.
Why AsyncLocalStorage, not a global
This is the single most important detail in the whole design, so it is worth being precise about the failure mode it avoids.
The tempting shortcut is to skip AsyncLocalStorage and just write the
bindings onto a global:
// DO NOT DO THIS — it races across concurrent requests.
export default {
async fetch(request, env, ctx) {
globalThis.__env = env; // last writer wins
return inner.fetch(request);
},
};
Here is exactly why that is broken.
A Cloudflare Workers isolate is single-threaded, but it is not
single-request. It interleaves many in-flight requests cooperatively:
whenever a handler hits an await (any async I/O — a fetch, a D1 query,
a KV read), it suspends and the event loop is free to run another
request’s handler in the same isolate, sharing the same globalThis.
Now trace two concurrent requests through the global-field version:
- Request A arrives. It writes
globalThis.__env = envA, thenawaits a slow D1 query. - While A is suspended on that
await, the event loop dispatches request B. B writesglobalThis.__env = envB, overwriting the field. - A’s query resolves. A resumes past its
awaitand readsglobalThis.__env— and seesenvB, B’s bindings, not its own.
This is a last-writer-wins data race. It is not a parallel-CPU race that a
mutex would fix — there is only one thread, and the writes never collide
mid-instruction. The corruption happens precisely because execution
yields at await boundaries: the global outlives the suspension, so the
value A reads after resuming is whatever the most recent request wrote.
Under load it surfaces as one tenant’s request reading another tenant’s
secrets or database handle — intermittently, and almost never in local
testing where requests rarely overlap.
AsyncLocalStorage fixes this at the right layer. als.run(store, cb)
binds store to the async continuation chain rooted at cb. Every
callback, every .then, every await-resumption that descends from that
run reads the store that was active when it was scheduled — not the
“current” value of a shared field. So when A resumes after its await, it
is still inside A’s run scope and reads envA; B’s concurrent run
scope is a completely separate store. Each request gets its own isolated
view, and interleaving is harmless.
📝 Why a globalThis key, then?
The store registry itself lives on globalThis under the stable key
__zfb_cf_adapter_als__. That is not the same thing as storing env on a
global. The wrapper (_worker.js) and your page bundle
(_zfb_inner.mjs) are separate ESM module graphs, so a module-level
const als = new AsyncLocalStorage() in one would be a different
instance than the one the other imports. Pinning the single
AsyncLocalStorage instance to a known global key lets both ends share it.
The per-request data still lives inside the store, scoped by run — never
on the global.
The advanced-mode contract
The moment a _worker.js exists at the root of your Pages output, you opt
into advanced mode, and the routing rules change:
Every request hits your worker. Cloudflare Pages’ built-in static-asset routing is OFF unless your worker explicitly delegates to
env.ASSETS.
That built-in routing is not nothing — it is the layer that does
trailing-slash canonicalization (/docs/foo → 308 → /docs/foo/) and
resolves a directory to its index.html for SSG output. When you take
over the entry point, you also take over the responsibility for serving
those static files. That is what env.ASSETS.fetch(request) is: a handle
to the very asset server you just bypassed.
Why ASSETS-first, not router-first
It is tempting to let the framework’s router handle GET requests first and
only reach for env.ASSETS as a fallback. Do not. For a prerendered
(SSG) page, the inner router can re-render the page dynamically — but it
will produce HTML without the build-time head injection.
zfb build post-processes each prerendered HTML file to inject the
production <link rel="stylesheet"> and the <script type="module"> tags
that load your island hydration bundles. That injection is a build step,
not a runtime concern, so a dynamic SSR render of the same route emits the
un-injected HTML. The page would render, but its islands would never
hydrate — no stylesheet, no hydration script. Serving the prebuilt asset
via env.ASSETS.fetch is what preserves the injected head.
Hence the dispatch policy in the wrapper:
- GET / HEAD requests probe
env.ASSETS.fetch(request)first. If the asset server returns anything other than404, that response wins (the prebuilt, head-injected HTML, with correct canonicalization). - On a
404from ASSETS, fall through to the inner worker — this is where genuinely dynamic routes (prerender = false, e.g.pages/api/*.tsx) are served. - Non-GET/HEAD requests (
POST,PUT,PATCH,DELETE) skip the ASSETS probe entirely and go straight to the inner SSR worker. Pages assets are read-only, so probing would always404/405; a mutating request likePOST /api/ai-chatmust reach the SSR handler directly.
nodejs_compat is required
The wrapper imports AsyncLocalStorage from node:async_hooks. That
Node.js built-in is only available in Workers when the nodejs_compat
compatibility flag is enabled (with a sufficiently recent compatibility
date). Without it, the worker fails to load with an unresolved
node:async_hooks import. See
Compatibility dates for how to set
the flag and pick a compatibility date.
How it is tested
The adapter’s acceptance test imports the produced _worker.js directly
into vitest, builds a synthetic Request + env + ctx where env.DB is an
in-memory D1Database-shaped stub, drives a POST (so the wrapper
bypasses the ASSETS probe and goes straight to the inner worker), and
asserts that a page reading env.DB.prepare(...).all() sees the rows the
wrapper threaded in. Because the wrapper stores env verbatim and never
inspects it, this proves the architectural claim — “an SSR route can reach
env.DB” — without needing a live wrangler dev / miniflare run.