#10315·better-auth

Lazily-cached init promises hang forever on Cloudflare Workers when the initializing request is aborted

Author: cojiCreated Jul 5, 2026Updated Sep 30, 2026
Labelscore

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:

  1. Deploy a Worker (nodejs_compat) that caches the auth instance per isolate (cachedAuth ??= betterAuth(...)) and serves an authenticated JSON endpoint via auth.api.getSession({ headers }).
  2. 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.
  3. 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.
  4. 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:

  1. @better-auth/core/src/async_hooks/index.ts — module-level AsyncLocalStoragePromise = import("node:async_hooks")..., awaited by ensureAsyncStorage in endpoint-context.ts, request-state.ts, and transaction.ts until the storage instance is created. Every endpoint dispatch goes through runWithEndpointContext, so a poisoned promise here hangs all API calls.
  2. createBetterAuth — const authContext = initFn(options) cached on the instance ($context), awaited by every call.

Diagnosis evidence from our production incident:

  • A chrome://net-export capture 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 outcome canceled once the client gave up.
  • Phase markers (console.info around each await; canceled invocations flush logs) showed hung requests completed await auth.$context but auth.api.getSession({ headers }) never reached its first D1 query, while a raw env.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 with ensureAsyncStorage awaiting 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 AsyncLocalStorage via a static import in the workerd/edge bundle (nodejs_compat provides node:async_hooks), or create the storages synchronously when globalThis.AsyncLocalStorage exists.
  • Document (or expose an API for) eagerly initializing $context and the ALS storages outside a request, so integrators can anchor them with ctx.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_hooks import at global scope (keys requestStateAsyncStorage, endpointContextAsyncStorage, adapterAsyncStorage on globalThis[Symbol.for("better-auth:global")].context), so the dynamic-import path is never taken.
  • Anchor auth.$context to ctx.waitUntil once per isolate.
  • Wrap auth.api.getSession in a Promise.race hang detector that rebuilds the instance once and fails null.

What version of Better Auth are you using?

1.6.20

System info

bash
# 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-method

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

typescript
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 ensureAsyncStorage lazy initialization. Both bugs share the same root — AsyncLocalStorage resolved 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_hooks dynamic import failing to resolve), #8428 (bun build --compile).

Thanks for better-auth — happy to provide more detail or test a patch.