zudo-cloudflare-wisdom

Type to search...

to open search from anywhere

SSR Bindings via AsyncLocalStorage

CreatedMay 28, 2026Takeshi Takatsudo

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:

  1. Request A arrives. It writes globalThis.__env = envA, then awaits a slow D1 query.
  2. While A is suspended on that await, the event loop dispatches request B. B writes globalThis.__env = envB, overwriting the field.
  3. A’s query resolves. A resumes past its await and reads globalThis.__env — and sees envB, 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/foo308/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 than 404, that response wins (the prebuilt, head-injected HTML, with correct canonicalization).
  • On a 404 from 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 always 404/405; a mutating request like POST /api/ai-chat must 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.