Lazily-cached init promises hang forever on Cloudflare Workers when the initializing request is aborted
Is this suited for github?
- Yes, this is suited for github
Reproduction
The bug is a runtime-environment interaction (workerd's "hanging promise" semantics), so the reproduction is behavioral rather than a repo:
- Deploy a Worker (
nodejs_compat) that caches the auth instance per isolate (cachedAuth ??= betterAuth(...)) and serves an authenticated JSON endpoint viaauth.api.getSession({ headers }). - From a browser, issue authenticated fetches that are frequently superseded and aborted mid-flight (
AbortController.abort()on the in-flight request when a newer one starts) — e.g. a page that refetches on every user action. - Keep enough traffic that Cloudflare spins up fresh isolates while aborts are happening. Eventually an abort lands on the request that is lazily driving better-auth's first-touch initialization in a fresh isolate.
- From that point, every better-auth API call routed to that isolate hangs forever, before any DB access. Because a client's connection tends to stick to one isolate, that user sees a full outage while other users are unaffected.
Mechanism: on Workers, a promise created while handling a request belongs to that request's IoContext; when the client disconnects first, workerd never settles it. better-auth caches such promises across requests:
@better-auth/core/src/async_hooks/index.ts— module-levelAsyncLocalStoragePromise = import("node:async_hooks")..., awaited byensureAsyncStorageinendpoint-context.ts,request-state.ts, andtransaction.tsuntil the storage instance is created. Every endpoint dispatch goes throughrunWithEndpointContext, so a poisoned promise here hangs all API calls.createBetterAuth—const authContext = initFn(options)cached on the instance ($context), awaited by every call.
Diagnosis evidence from our production incident:
- A
chrome://net-exportcapture during an episode showed the HTTP/2 connection was healthy (h2 PINGs ACKed in milliseconds throughout; edge-served static assets on the same connection responded normally) — only Worker-bound streams got no response, and invocations ended with outcomecanceledonce the client gave up. - Phase markers (
console.infoaround each await; canceled invocations flush logs) showed hung requests completedawait auth.$contextbutauth.api.getSession({ headers })never reached its first D1 query, while a rawenv.DB.prepare('SELECT 1')probe in the same isolate succeeded. So D1 and the isolate event loop were fine; the hang sat in the dispatch layer before DB access, consistent withensureAsyncStorageawaiting a permanently pending cached import promise. - For us the trigger became deterministic: two quick comment posts on a viewer page (firing supersede-aborts of in-flight authenticated fetches) reproduced the poisoned-isolate state almost every time.
Current vs. Expected behavior
Current: after a client abort lands on the request driving lazy initialization in a fresh isolate, every later better-auth API call in that isolate awaits a permanently pending cached promise and hangs forever (no error, no timeout). The isolate never recovers until it is recycled.
Expected: initialization is abort-safe — a client abort during first-touch initialization must not poison the cached state for subsequent requests. Options that would achieve this:
- Resolve
AsyncLocalStoragevia a static import in the workerd/edge bundle (nodejs_compatprovidesnode:async_hooks), or create the storages synchronously whenglobalThis.AsyncLocalStorageexists. - Document (or expose an API for) eagerly initializing
$contextand the ALS storages outside a request, so integrators can anchor them withctx.waitUntil. - Or avoid caching pending promises: cache only resolved values and retry initialization if a previous attempt never settled within a bound.
Workarounds we shipped (verified: the deterministic reproduction no longer hangs):
- Pre-seed the three AsyncLocalStorage instances from a static
node:async_hooksimport at global scope (keysrequestStateAsyncStorage,endpointContextAsyncStorage,adapterAsyncStorageonglobalThis[Symbol.for("better-auth:global")].context), so the dynamic-import path is never taken. - Anchor
auth.$contexttoctx.waitUntilonce per isolate. - Wrap
auth.api.getSessionin aPromise.racehang detector that rebuilds the instance once and fails null.
What version of Better Auth are you using?
1.6.20
System info
# npx auth info is not practical inside the Workers runtime; environment summary:
better-auth: 1.6.20
@better-auth/core: 1.6.20
runtime: Cloudflare Workers (workerd), compatibility_flags: ["nodejs_compat"]
database: Cloudflare D1 via kysely-d1 0.4.0 (Kysely 0.29.2), passed as { database: { dialect, type: "sqlite" } }
framework: React Router 8 (framework mode) on the same Worker
plugins: jwt, oauth-provider, device-authorization, email-otp, last-login-methodWhich area(s) are affected? (Select all that apply)
Backend
Auth config (if applicable)
import { betterAuth } from "better-auth"
import { D1Dialect } from "kysely-d1"
const dialect = new D1Dialect({ database: env.DB })
let cachedAuth: ReturnType<typeof buildAuth> | undefined
export function createAuth() {
return (cachedAuth ??= buildAuth())
}
function buildAuth() {
return betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: { dialect, type: "sqlite" },
plugins: [jwt(), oauthProvider({ /* ... */ }), deviceAuthorization({ /* ... */ }), emailOTP({ /* ... */ }), lastLoginMethod()],
socialProviders: { google: { /* ... */ }, microsoft: { /* ... */ } },
})
}Additional context
- Only reproducible under real client-abort traffic on Workers; local dev and non-aborting load tests never hit it.
- #10300 reports a different failure mode (a check-await-assign concurrency race) in the same
ensureAsyncStoragelazy initialization. Both bugs share the same root —AsyncLocalStorageresolved through an awaited, lazily-cached dynamic import driven by request traffic — and eager initialization from a static import would fix both. - Related past reports of the dynamic-import fragility in other environments: #5539 (
node:async_hooksdynamic import failing to resolve), #8428 (bun build --compile).
Thanks for better-auth — happy to provide more detail or test a patch.
Source: better-auth/better-auth