Skip to content

firebase-frameworks: handleAuth 500s under concurrent same-uid requests (uid-keyed LRU dispose deletes an in-flight Firebase App) #685

Description

@NeilAyers

Package

firebase-frameworks 0.11.8 (packages/firebase-frameworks), running as the Cloud Run SSR backend created by firebase deploy for a frameworksBackend Hosting site. Framework is Next.js 14.2 (App Router), Node 24.

Summary

handleAuth in src/firebase-aware.ts keys its firebaseAppsLRU on uid, and that cache is configured with dispose: (value) => deleteApp(value). Concurrent requests carrying the same __session cookie therefore destroy each other's Firebase App while it is still being signed in, and every loser of the race throws app/app-deleted out of handleFactory, which has no catch. Express answers HTTP 500.

The framework never runs for those requests: handleAuth throws before frameworkHandle(req, res) is reached, so the response is Express's 176 byte default error body, not anything the app rendered.

The race

const firebaseAppsLRU = new LRU({
  max: LRU_MAX_INSTANCES,
  ttl: LRU_TTL,                                 // 5 minutes
  allowStale: true,
  updateAgeOnGet: true,
  dispose: (value) => { deleteApp(value); },    // (1)
});

const handleAuth = async (req, res) => {
  // ...
  let app = firebaseAppsLRU.get(uid);
  if (!app) {
    const random = Math.random().toString(36).split(".")[1];
    const appName = `authenticated-context:${uid}:${random}`;
    app = initializeApp(undefined as any, appName);
    firebaseAppsLRU.set(uid, app);              // (2) disposes whatever was under `uid`
  }
  const auth = getAuth(app);
  if (auth.currentUser?.uid !== uid) {
    const customToken = await adminAuth.createCustomToken(uid);
    await signInWithCustomToken(auth, customToken);   // (3) throws if (1) fired for this app
  }
  // ...
};

With N concurrent same-uid requests and a cold entry for that uid:

  1. All N call firebaseAppsLRU.get(uid) and miss, because none of them has reached its set yet.
  2. All N build a distinct app name (the random suffix) and call initializeApp.
  3. Each set(uid, app) at (2) evicts the value the previous request just stored, and eviction runs dispose, i.e. deleteApp, on an app that request is still using.
  4. The N-1 losers are parked on await adminAuth.createCustomToken(uid) at that moment. When they resume into signInWithCustomToken, their app is gone.

Only the last setter survives. N-1 requests return 500.

Observed in production

Six distinct uids over 14 days on one Next.js site. A representative burst, five simultaneous App Router link prefetches issued from one page right after sign in:

request status latency
/journal?_rsc=... 500 0.162s
/journal/<id>?_rsc=... 500 0.205s
/resources?_rsc=... 500 0.223s
/journal/new?_rsc=... 200 0.969s
/dictionary?_rsc=... 500 0.237s

and exactly four errors, one per failed request, with four different random suffixes for the same uid:

FirebaseError: Firebase: Firebase App named
  'authenticated-context:<uid>:yhw01qpkh5m' already deleted (app/app-deleted).
    at FirebaseAppImpl.checkDestroyed (@firebase/app/dist/esm/index.esm.js:439:33)
    at get options (@firebase/app/dist/esm/index.esm.js:417:14)
    at AuthImpl._getAdditionalHeaders (@firebase/auth/.../totp-5d40279f.js:3150:22)
    at _performFetchWithErrorHandling (@firebase/auth/.../totp-5d40279f.js:928:13)
    at _performApiRequest (@firebase/auth/.../totp-5d40279f.js:882:12)
    at _performSignInRequest (@firebase/auth/.../totp-5d40279f.js:976:34)
    at signInWithCustomToken$1 (@firebase/auth/.../totp-5d40279f.js:5772:12)
    at signInWithCustomToken (@firebase/auth/.../totp-5d40279f.js:5815:28)
    at handleAuth (file:///workspace/node_modules/firebase-frameworks/dist/firebase-aware.js:80:15)

4 apps created, 4 disposed by later sets, 4 x 500, 1 survivor. The identical 4 fail / 1 pass split reproduced for a second uid ten minutes later.

The client's immediate retry succeeds, because by then the entry is warm and every request takes the get hit path.

When it fires

Any burst of same-uid requests that lands on a cold cache entry:

  • the first navigation after sign in;
  • the first request after LRU_TTL (5 minutes) of idle;
  • the first burst on a newly scaled Cloud Run instance.

Next.js App Router makes this routine rather than exotic: it prefetches every in-viewport <Link> concurrently, so a single page load issues 5+ simultaneous same-uid requests, and Cloud Run containerConcurrency is 80 so they land on one instance and race each other.

Reproduction

  1. Deploy any Next.js App Router site with frameworksBackend and the Firebase JS SDK present, so isUsingFirebaseJsSdk() is true and handleFactory wraps the handler.
  2. Sign in and set a __session session cookie.
  3. Load a page with five or more <Link>s in the viewport, or just fire five concurrent requests with the same cookie.
  4. Four of the five return 500 with a 176 byte body, and the logs show four app/app-deleted errors.

Possible fixes

Any one of these closes it:

  1. Do not evict an app that is still in flight. Guard the store: if (!firebaseAppsLRU.has(uid)) firebaseAppsLRU.set(uid, app); else deleteApp(app); so the first writer wins and later arrivals discard their own app instead of destroying somebody else's.
  2. Cache the promise, not the app. Store an in-flight Promise<FirebaseApp> under the uid so concurrent requests share one initialization rather than each performing their own.
  3. Catch in handleFactory. Even with 1 or 2, await handleAuth(req, res) should not be able to turn an auth-context failure into a 500 for a page that does not need the auth context. Falling through to frameworkHandle unauthenticated is strictly better than a blank 500.

1 or 2 is the real fix; 3 is worth having regardless, since today any throw inside handleAuth takes down the request.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions