Conversation
same-uid requests Concurrent requests for one uid each created their own app, and every set() disposed the previous one while it was still signing in. The losers threw app/app-deleted and the request ended in a 500. Now: - the cache is re-checked after the await so only the first request creates the app - allowStale is gone because a stale get() handed back an already deleted app - an auth failure falls through as an unauthenticated request. Fixes #685
There was a problem hiding this comment.
Code Review
This pull request removes the allowStale option from the LRU cache, handles concurrent app initialization in handleAuth by checking the cache again after awaiting session cookie verification, and catches authentication errors in handleFactory to treat them as unauthenticated requests. The review feedback highlights a potential race condition and performance bottleneck with concurrent requests executing signInWithCustomToken simultaneously, suggesting caching the sign-in promise on the app instance to prevent redundant operations.
| // A concurrent request may have initialized the app while we awaited | ||
| app = firebaseAppsLRU.get(uid); |
There was a problem hiding this comment.
Concurrency Issue with signInWithCustomToken
While this PR successfully prevents multiple FirebaseApp instances from being initialized concurrently, there is still a potential race condition and performance bottleneck with signInWithCustomToken.
The Problem
When multiple concurrent requests for the same uid arrive:
- The first request initializes the
FirebaseAppand adds it to the LRU cache. - The subsequent concurrent requests bypass the
!appcheck because they retrieve the newly createdappfrom the LRU cache. - However, because the first request's
signInWithCustomTokenis asynchronous and has not completed yet,auth.currentUseris stillnullfor all subsequent requests. - As a result, all concurrent requests will pass the
auth.currentUser?.uid !== uidcheck and concurrently calladminAuth.createCustomToken(uid)andsignInWithCustomToken(auth, customToken).
This leads to:
- Multiple redundant network requests to the Firebase Auth backend (Identity Toolkit API) for the same user session.
- Potential race conditions in the Firebase Auth state listener.
- Unnecessary CPU overhead from multiple
createCustomTokencalls.
Suggested Solution
We can cache the active sign-in promise directly on the app instance to ensure that concurrent requests for the same app await the same sign-in operation:
const auth = getAuth(app);
if (auth.currentUser?.uid !== uid) {
// Cache the sign-in promise on the app instance to prevent concurrent sign-ins
let signInPromise = (app as any)._signInPromise;
if (!signInPromise) {
signInPromise = (async () => {
// TODO(jamesdaniels) get custom claims
const customToken = await adminAuth
.createCustomToken(uid)
.catch((e: Error) => console.error(e.message));
if (!customToken) return;
await signInWithCustomToken(auth, customToken);
})();
(app as any)._signInPromise = signInPromise;
const cleanUp = () => {
delete (app as any)._signInPromise;
};
signInPromise.then(cleanUp, cleanUp);
}
await signInPromise;
}
Fixes #685
Problem
firebaseAppsLRUis keyed by uid and disposes withdeleteApp. Two separate paths hand a request an app that is already being deleted.Concurrent requests with the same
__sessioncookie all miss the cache, so each one callsinitializeAppand eachset()deletes the app the previous request is still signing in with. The losers throwapp/app-deleted,handleFactorydoes not catch, and Express answers 500.A single request does it too, once the entry goes stale. A stale
get()deletes the entry, which firesdisposeanddeleteApp, then returns that same app.deleteAppis async and nothing awaits it, and the rest ofhandleAuthis synchronous, so the request never notices. Teardown finishes while the page is awaiting, and the next use ofres.locals.firebaseAppthrows.Fix
allowStale.handleFactory, so an auth failure degrades to an unauthenticated request instead of a 500.Verification
Next.js 14.2 with the Firebase JS SDK, deployed to Hosting preview channels. Statuses come from the SSR function's own request log.
App Router, the reported scenario: eight
<Link>s mount right after sign-in and prefetch at once against a cold entry.app/app-deleted, one 200The 0.11.8 latencies match the table in the issue.
The stale path was checked on its own, with a page that awaits
getIdToken()and then readsapp.options. Once the entry is left idle past its 5 minuteLRU_TTL, 0.11.8 serves the stale entry and the page fails withapp/app-deleted, while this branch re-initializes and succeeds.