Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 Why We Keep libSQL Clients Inside Each Worker Request

TEEI in Practice

Engineering noteResearch & Engineering

Part of: Cloudflare Workers

Why We Keep libSQL Clients Inside Each Worker Request

A libSQL client shared across Worker requests hung requests in our test lane. We moved the cache into each request and kept reuse within it.

On this page
  1. What we saw
  2. Why a shared client breaks on a Worker
  3. One registry per request
  4. What one page asks of the client
  5. The key decides which calls share a client
  6. The regression the same evening
  7. The tests that guard it
  8. Test the identity contract separately from the runtime failure
  9. When a new client per call is the better choice
  10. Verify this
  11. Appendix: configuration
  12. Frequently asked questions

Until 9 September 2026 we cached one libSQL client per database URL at module scope in Evenhall, our React Router 8 application on Cloudflare Workers, and handed it to every request that asked for that URL. In a local run with one browser client at a time, the pages worked. With several in parallel, our test lane recorded requests the runtime cancelled as hung, warnings about promises resolved from another request’s context, and HTTP 500 responses. We moved the cache into each request with AsyncLocalStorage and kept reuse within the request. We also had to preserve which calls received the same client, and our first attempt did not.

Evenhall is the workspace where a partner company’s managers see what their employees did through TEEI’s programmes, and turn it into a report they can file. The figures are built from the sessions that employees held as volunteers in TEEI Language, TEEI Mentorship and Skills Academy, recorded against their company; How corporate volunteering works at TEEI explains how a session is counted. Every page a manager opens, from the impact overview to the monthly report, is a request to Evenhall’s Worker, and the figures on it are read through one resolver, getClient. As of 17 October 2026, that data is in Turso, reached through @libsql/client/web over HTTP. How TEEI runs its platforms on Cloudflare Workers describes the account, the Workers and the database it sits beside.

Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.

What we saw

We found this on 9 September 2026 in our browser test lane, not through a user report, and our evidence documents no production incident. In that lane we drive a candidate build with Playwright and several browser clients in parallel. After the change, under the same load and serving setup, it recorded no cancellations, no cross-request warnings and no HTTP 500 responses; the totals from before stay internal. That controlled comparison is our evidence for the cause, not a trace of the client’s internals.

Why a shared client breaks on a Worker

A Workers isolate is reused, and it can serve several requests at once: “a single Workers instance may handle multiple requests including concurrent requests in a single-threaded event loop”. A map at module scope therefore outlives the request that filled it, as the Workers best practices warn: “A variable set during one request is still present during the next.” I/O, however, belongs to a request. The Workers error reference says that I/O objects “created in the context of one request handler cannot be accessed from a different request’s handler”, and names caching an I/O object in global scope as the most common cause. Since the compatibility date 2024-10-14, handle_cross_request_promise_resolution is on by default, and with it “promise continuations are scheduled to run in the correct request context if it is still alive, or dropped with a warning if the correct context has already ended.” Evenhall’s compatibility date is 2026-04-01, so the flag is on.

A libSQL client over HTTP is a set of fetch calls and the promises around them. The commit that fixed our code explains what happens when one is shared: its Hrana promises belong to whichever request first touched it, so a later request awaiting them trips the runtime guard, the continuations are cancelled and the shared client never answers again. Hrana is the protocol the libSQL client speaks over HTTP. In that explanation the awaiting handler never resumes, and the runtime cancels the request as hung. We recorded the warnings and the cancellations, but we did not trace the client’s internal promise chain, or which continuation led to which cancelled request.

Two sequence diagrams, one above the other. The upper one shows the causal model described in the fix commit, not a traced promise chain. Above, before the fix, request A creates a database client and stores it in a map at module scope, request B later gets the same client, waits on a promise that belongs to request A, and the runtime drops the continuation with a warning, so request B never answers. Below, after the fix, requests A and B each run inside their own scope with their own map and get separate clients, and code outside a scope uses the module map.
Figure 1. One client shared across requests, and one map per request. The upper diagram is the explanation in the commit that fixed it, matched by our before and after in a test lane on 9 September 2026, not a trace from production. Source: TEEI, drawn from our platform code on 16 October 2026.

A shared client also shares its concurrency limiter: in @libsql/client 0.18.0, as we read it on 15 October 2026, each HTTP client has its own, with a documented default of 20 concurrent requests. We have not shown whether it contributed; our before and after cannot separate it from the dropped continuations.

The cache we had looked like this:

Adapted for publication.

ts
const clients = new Map<string, Client>();
// ...
  const existing = clients.get(url);
  if (existing) return existing;
  const client = createClient({ url, authToken });
  clients.set(url, client);
  return client;

Before 9 September 2026, we also kept two module-level clients: one for OAuth state and one for support form submissions. The first request that asked created each client, and later requests in the isolate reused it.

One registry per request

We kept the cache and moved it from the module into the request. Node’s AsyncLocalStorage gives every asynchronous operation started inside run(store, callback) access to that store through getStore(), and Workers implement it (Cloudflare’s AsyncLocalStorage reference). Outside a context started with run(), getStore() returns undefined. That gives us both behaviours we need: a fresh map for every request, and the old module map for code that does not run inside one, such as scripts and unit tests.

This is the first version, as we committed it on 9 September:

Adapted for publication.

ts
import { AsyncLocalStorage } from "node:async_hooks";
import { createClient, type Client } from "@libsql/client/web";

const clients = new Map<string, Client>();
export const requestScope = new AsyncLocalStorage<Map<string, Client>>();

export function runWithRequestDbScope<T>(fn: () => Promise<T>): Promise<T> {
  return requestScope.run(new Map(), fn);
}
// ...
  const cache = requestScope.getStore() ?? clients;
  const existing = cache.get(url);
  if (existing) return existing;
  const client = createClient({ url, authToken });
  cache.set(url, client);
  return client;

Within a scope we create and cache the client synchronously, with no await between the lookup and the insertion, so every later call for that URL in the scope gets the same object. We add no client cleanup to the wrapper, and we have not measured its time or memory cost.

Evenhall’s Worker passes each request through a middleware chain of our own before the React Router handler runs. The chain now runs inside the scope:

Adapted for publication.

ts
export async function runMiddleware(request, env, ctx, next) {
  // ... the URL, method, start time and request id are read first ...
  return runWithRequestDbScope(async () => {
    // ... the middleware chain and the React Router handler, unchanged ...
  });
}

We enter the scope before middleware and routes look up their clients. The scope selects a registry for later lookups; it does not replace Client references a caller already holds. The resolver reads the active map wherever it runs in that asynchronous chain, so no client is passed through the calls. Every call to runWithRequestDbScope, nested or not, starts a new map, so the scope belongs around the request and not around each lookup.

Outside a scope, getClient silently uses the module map. That is what scripts and tests want, and it is what a request handler must never get. Every request path that uses this resolver has to run inside a scope; in Evenhall, the shared middleware opens it, and the resolver itself does not report a missing scope. The scope therefore covers HTTP requests: the Worker’s fetch handler passes each one to that middleware. The same Worker’s queue() and scheduled() handlers call the same resolver without opening a scope, so they use the module map, and a later invocation in the same isolate can reuse a client an earlier one created. The per-request isolation in this note applies to HTTP requests; we have not examined whether those handlers need a scope of their own.

On a page, the reuse looks like this. When a manager opens the impact overview, its loader reads the company and then starts four independent data helpers at once with Promise.all. None of them is handed a client: a helper that reads the database calls getClient itself and names the company. They resolve the same URL, so inside the request’s scope they share one client, and another manager’s request, which the same isolate may be serving at that moment, gets its own. The contribution record and the monthly report read through the same resolver.

Three phone screens from Evenhall's public demo, with sample data, each with a numbered label. One, the impact overview: 4,932.50 verified hours your people gave to 1,066 learners this year; one request runs the layout's and the overview's loaders in parallel, and the overview's loader starts four helpers together; reads for the same database share one client within the request. Two, the contribution record: 888 colleagues at the sample company Horizon Technologies Holdings have held 3,561 sessions; its loader resolves the client once, naming the company, and hands it to each read in turn. Three, the report for October 2026, a month still open: 690 sessions, 895.25 volunteer hours and 461 people; the same loader, which also reads the source the month's figures are built from.
Figure 2. Three Evenhall pages a manager opens, each one request to the Worker, and what that request asks of the database client. Source: TEEI, captured from Evenhall’s public demo on 23 October 2026. The company and every figure are sample data.

The screens come from Evenhall’s public demo, which runs the same pages and the same resolver against its own database of sample data, so no partner’s data is in it.

What one page asks of the client

The four helpers are only part of the overview’s request. Opening the Impact tab runs the loader of every route it matches, and React Router runs them in parallel. Two of them read the database: the manager layout’s, which draws the sidebar and the top bar, and the overview’s own, whose data the Impact, People, Operations and Compliance tabs all read. The root route’s loader reads only the request and the environment, and the Impact tab’s own loader returns nothing.

Both loaders need the signed-in manager and the company, and we keep that work per request as well, under a different key. A WeakMap at module scope maps each Request object to the promise of that work. The first loader to ask starts it, the second awaits the same promise, and a failure removes the entry so that a later call can try again. One test runs the layout’s loader and the shared lookup together on the same Request and checks that the sign-in check and the company lookup each run once; another gives them two requests and checks that each runs twice. A promise shared this way never crosses a request: both loaders belong to the request that created it, which is what the runtime allows, and the opposite of a client created in one request and awaited in another. Keying by the Request works because every loader is handed it. The helpers further down are not, which is why getClient finds its registry through AsyncLocalStorage instead.

Then come the reads. For a partner company, outside the demo, and as the code stood on 17 October 2026, the layout’s loader sends one statement for the counts behind the sidebar’s badges and one for the number of workspaces the manager can switch between. The overview’s loader reads the company’s reporting settings and then starts its four helpers. The headline figures send five statements at once, the strategic figures five, and the trust summary for the Compliance tab four, while the Impact tab’s opening ledger sends its four statements as one batch. When all four have returned, three reads for the action inbox go out together. The four helpers therefore start fourteen single statements and one batch at the same moment. Every one of these calls resolves the same URL, so they share one client, and its limiter is shared only with the rest of the same request. A client cached for the whole isolate would put two managers’ overviews behind one default of 20.

Each helper also carries its own .catch. A helper that fails falls back to empty figures for its part of the page, and the rest still renders. Those fallbacks cover a read that fails; they cannot cover one that never answers. A .catch runs when a promise rejects, and a continuation the runtime drops never settles either way. A client that stops answering therefore does not reduce the overview to empty panels: the request waits until the runtime cancels it.

The contribution record and the monthly report share one loader, and it takes the other approach. It resolves the client once, naming the company, and hands that client explicitly to its reads in turn: the record itself, then the basis for the period the page shows, and, for the report, the source the month’s figures are built from. Each of those reads has its own fallback, so a failed basis or report says what is unavailable instead of blanking the page. Naming the company is what sends the public demo to its own database of sample data; without it, the call would resolve the primary database for every company.

The registry still matters on that page. Before it resolves the client, the loader looks the company up, and that lookup calls getClient itself. For a partner company it resolves the same URL, so the client the loader then passes down is the one the lookup already created. Both approaches live side by side in Evenhall: helpers that look the client up where they need it, and a loader that resolves it once and passes it down. Passing it down makes the sharing visible in the code; looking it up relies on the registry. Inside a request scope, both end with one client per URL.

The key decides which calls share a client

Isolating requests was the easy half. The OAuth and support helpers had a contract that the first client wins: every call within a request must get the same client, whichever URL it resolves to. They now hold their client under a constant key inside the same per-request map:

Adapted for publication.

ts
const OAUTH_CLIENT_KEY = "oauth-client";
const clients = new Map<string, Client>();

function getOAuthClient(runtimeEnv?: Record<string, unknown>): Client {
  const cache = requestScopeStore() ?? clients;
  const existing = cache.get(OAUTH_CLIENT_KEY);
  if (existing) return existing;
  // ... resolve the URL and token, create the client, cache.set(OAUTH_CLIENT_KEY, client) ...
}

Our first round of the change keyed these two by URL, as the main client is, and that broke the contract while request isolation still held. The difference is the order of the steps. The main getClient resolves a URL from its environment and then looks it up, so one request can hold several URL-keyed clients. The helpers look up their constant key first, so every later call in the same request returns the first client, even if its environment argument differs. The scope decides which registry a request uses; the key decides which calls inside it share a client.

Our integration tests use the key too. The two files we inspected on 15 October 2026 open a scope and insert a test client under the URL that getClient will resolve, so the ordinary lookup returns it before reaching createClient, and the test swaps the database without changing the caller.

The regression the same evening

The first version constructed the AsyncLocalStorage when the module loaded, and that module was also reachable from the browser. An indirect import from a layout brought the database client module into the client bundle. For the browser, the bundler replaces node:async_hooks with a shim that has no AsyncLocalStorage class, and the pages that loaded the module threw at load with AsyncLocalStorage is not a constructor.

The import was older than the fix. As long as the module only created a Map at load, nothing in the browser noticed. One line that constructs a Node class at module scope turned an unnoticed import into a broken page, and we found it in a later lane run.

An indirect import from a layout brings the database client module into the browser bundle. There the bundler’s shim for node:async_hooks has no AsyncLocalStorage. The first version constructed it at load and threw.
Figure 3. How a server module reached the browser, and why constructing AsyncLocalStorage at load broke the pages that loaded it. Source: TEEI, drawn from our platform code on 16 October 2026.

We fixed it the same evening by building the storage on first use, and only when the class exists. This is the code in Evenhall today:

Verbatim excerpt.

ts
const clients = new Map<string, Client>();
let scope: AsyncLocalStorage<Map<string, Client>> | null | undefined;

function requestScopeOrNull(): AsyncLocalStorage<Map<string, Client>> | null {
  if (scope !== undefined) return scope;
  scope =
    typeof AsyncLocalStorage === "function" ? new AsyncLocalStorage() : null;
  return scope;
}

export function requestScopeStore(): Map<string, Client> | undefined {
  return requestScopeOrNull()?.getStore();
}

export function runWithRequestDbScope<T>(fn: () => Promise<T>): Promise<T> {
  const requestScope = requestScopeOrNull();
  return requestScope ? requestScope.run(new Map(), fn) : fn();
}

On the server nothing changes. In the browser, requestScopeStore() answers undefined and runWithRequestDbScope simply calls the function; getClient and the two helpers read the same accessor. The guard makes the module safe to load in the browser. Finding imports like this one is the subject of a separate note on what belongs in a React Router client bundle.

The tests that guard it

We guard this behaviour with four tests in two files, which ran green on 15 October 2026. The first file checks that one scope gets the same client back across an await, that overlapping scopes asking for the same URL get different clients, and that the module map still answers outside a scope. The middle test carries the fix:

Adapted for publication.

ts
import { expect, it } from "vitest";
import { getClient, runWithRequestDbScope } from "./db";

const runtimeEnv = { DATABASE_URL: "<url>", DATABASE_TOKEN: "<token>" };

it("isolates the same URL between overlapping scopes", async () => {
  const [first, second] = await Promise.all([
    runWithRequestDbScope(async () => {
      const client = getClient(runtimeEnv);
      await Promise.resolve();
      expect(getClient(runtimeEnv)).toBe(client);
      return client;
    }),
    runWithRequestDbScope(async () => {
      const client = getClient(runtimeEnv);
      await Promise.resolve();
      expect(getClient(runtimeEnv)).toBe(client);
      return client;
    }),
  ]);
  expect(first).not.toBe(second);
});

The test contacts no database; it only compares objects. The await Promise.resolve() makes both callbacks yield, so each must still find its own client after the other has run in between.

The second test mocks node:async_hooks the way the bundler hands it to the browser, then imports the module:

Adapted for publication.

ts
import { expect, it, vi } from "vitest";

vi.mock("node:async_hooks", () => ({ AsyncLocalStorage: undefined }));

it("uses clients when the async-hooks shim has no AsyncLocalStorage", async () => {
  const { getClient, runWithRequestDbScope } = await import("./db");
  expect(() => getClient(runtimeEnv)).not.toThrow();
  await expect(
    runWithRequestDbScope(async () => getClient(runtimeEnv)),
  ).resolves.toBeDefined();
});

On 15 October 2026 we checked both, in the reproduction under “Verify this”, with deliberate regressions. When runWithRequestDbScope calls the function directly and getClient reads only the module map, the isolation test fails because both scopes get the same client. When we construct the storage at module scope, the browser test fails with the same AsyncLocalStorage is not a constructor the browser showed. The tests run in Node, so they do not reproduce request cancellation in the Workers runtime or build a browser bundle, and they do not check the helpers’ first-client contract, several URLs in one scope, or nested scopes.

Test the identity contract separately from the runtime failure

The registry has two independent contracts: requests must not share clients, and calls that should share a client inside one request must keep doing so. Testing only the first would have missed our helper regression. Extend the reproduction with these cases before applying the pattern to another resolver:

Table 1. Expected client identities in the request-scoped design.This is a test specification derived from the lookup rules, not a new Workers measurement.
Calls under testExpected identityWhy
Same URL twice in one scope, with an await betweenSame objectThe scope keeps its registry across asynchronous work
Same URL in overlapping scopesDifferent objectsEach request owns a separate registry
Two URLs in one scopeDifferent objectsThe main resolver keys by URL
Inner scope, then a lookup after it returnsOuter client restoredA nested run has its own map and must not replace the outer one
Helper called twice with different environments in one scopeFirst helper clientIts constant key is looked up before resolving the environment

Use object identity for these assertions, with a client factory that sends no query. Then run a separate Workers-runtime case if you need evidence about cancellation or cross-request I/O. A green identity test establishes the registry rule; it does not establish that every application entrypoint opened a scope or that a particular promise chain caused the original hang.

When a new client per call is the better choice

On the shared platform for TEEI Language and TEEI Mentorship, we met the same class of failure and chose differently: we create a new client on each call and cache nothing. Reduced to its core, and simplified from the code as it stands:

Adapted for publication.

ts
export function getClient(runtimeEnv): Client {
  const url = readEnv(runtimeEnv, "DATABASE_URL");
  const authToken = readEnv(runtimeEnv, "DATABASE_TOKEN");
  if (!url || !authToken) throw new Error("Missing database configuration");
  return createClient({ url, authToken }); // new instance per call
}

For the http: and https: URLs we use, creating a client sends no request until a query runs. With a ws: or wss: URL, the same @libsql/client/web entry point opens a WebSocket as soon as the client is created. In its Hyperdrive troubleshooting guide, Cloudflare recommends: “Create a new database client on every request instead of caching it in a global variable.”

If no caller depends on getting the same client twice within a request, and no test injects a client, a new client per call removes the registry and its scope boundary, and gives every call its own limiter. In Evenhall, we keep the registry because our helpers and tests rely on that identity. We have not measured the cost difference between the two.

The language platform still keeps something per request, but it is a queue of reads, not a client. The code above leaves it out: inside the scope our Worker opens around each HTTP request, getClient wraps the new client so that the reads it recognises in execute wait in an AsyncLocalStorage store until a zero-delay timer fires, then go out together in read batches of bounded size, or alone when only one is waiting. A write, a transaction or an explicit batch sends the waiting reads first. Outside a scope, getClient returns the client unchanged; apart from HTTP requests, one credential job opens a scope of its own for its batches of reads. What that did to the number of round trips is in a separate note on why our Workers run near the database rather than near the user.

Verify this

You can reproduce the isolation and the browser guard without a database and without a Worker. Make db.ts from the code in “The regression the same evening”, with the two imports from the first version at the top, and add this getClient:

Adapted for publication.

ts
export function getClient(env: { DATABASE_URL: string; DATABASE_TOKEN: string }): Client {
  const url = env.DATABASE_URL;
  const cache = requestScopeStore() ?? clients;
  const existing = cache.get(url);
  if (existing) return existing;
  const client = createClient({ url, authToken: env.DATABASE_TOKEN });
  cache.set(url, client);
  return client;
}

Save the tests beside it as iso.test.ts and browser.test.ts, in separate files because the mock applies to a whole file. In each, use const runtimeEnv = { DATABASE_URL: "http://localhost:1", DATABASE_TOKEN: "x" }; (in the browser test, below the vi.mock line); these are dummy values, and the tests send no query. Then run npx vitest run. We ran them on 15 October 2026 at 14:34 UTC with Vitest 5.0.0 and @libsql/client 0.18.0: two tests, both passed.

The runtime warning and the cancelled requests need the Workers runtime, and we have not run a minimal version of that. Our before and after came from an internal test lane that readers cannot inspect.

Appendix: configuration

As read from Evenhall’s configuration and the installed packages on 15 October 2026.

  • Compatibility date 2026-04-01, with the nodejs_compat flag, which provides AsyncLocalStorage (Node.js compatibility in Workers). handle_cross_request_promise_resolution is on by default from 2024-10-14, and we do not set its opposite.
  • @libsql/client 0.18.0, imported as @libsql/client/web. The HTTP client’s concurrency limit is left at its default of 20.
  • react-router 8 and wrangler 4, as declared in Evenhall’s package.json.

Frequently asked questions

Can I cache a database client in global scope in a Cloudflare Worker?

In Evenhall we do not share the HTTP libSQL client across requests. One Worker instance can serve several requests at once, and an I/O object belongs to the request that created it. In its Hyperdrive troubleshooting guide, Cloudflare recommends creating a new database client on every request instead of caching it in a global variable.

What does the warning about a promise resolved from a different request context mean?

Since the compatibility date 2024-10-14, a compatibility flag is on by default that runs a promise continuation in the request context it belongs to if that request is still alive, and drops it with a warning if it has ended. A client shared between requests is one way to end up there.

How do I give each Worker request its own database client with AsyncLocalStorage?

Open a scope with run(new Map(), handler) around the middleware chain and the React Router handler, and have getClient read the active store before it falls back to the module map. Overlapping HTTP requests then get separate clients. Code that runs outside a scope keeps the module map: in Evenhall, scripts, unit tests and the Worker's queue and cron handlers.

Why does the browser report 'AsyncLocalStorage is not a constructor'?

A module that constructs AsyncLocalStorage at load time was bundled into client code, and the bundler replaced node:async_hooks with a browser shim that has no such class. We build the storage on first use, only when the class exists, and run the function without a scope when it does not.

Is a new libSQL client per call better than a per-request registry?

It is simpler, and we use it on our TEEI Language and TEEI Mentorship platform: over an HTTP URL, creating the client sends no request until a query runs. In Evenhall we use a registry because some callers depend on getting the same client within a request, and our tests inject a client into that scope. We have not measured the cost difference.

More in Research & Engineering