firebase / firebase/apphosting-adapters
firebase-frameworks: handleAuth 500s under concurrent same-uid requests (uid-keyed LRU dispose deletes an in-flight Firebase App)
- Dominant language
- TypeScript
- Stars
- 478
- Forks
- 1.5k
- Avg merge
- 3d 22h
- Merged PRs (30d)
- 2
Description
### 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
```ts
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/?_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::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 `set`s, 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 `` 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 ``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` 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.
Contributor guide
Research direction
Start in packages/firebase-frameworks/src/firebase-aware.ts at handleAuth and handleFactory, then reproduce the cold-cache race with concurrent requests carrying the same __session cookie. Done means same-uid bursts no longer produce app/app-deleted errors or HTTP 500 responses, and framework handling can proceed as described.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- firebase, nextjs, node.js, typescript
- Domain
- authentication, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100