auth/popup-closed-by-user fires 8 s late: cause and fix
Your login button can come back to life a fraction of a second after the user closes the Google popup, without patching the SDK or breaking slow sign-ins.
The complaint in firebase/firebase-js-sdk issue #8061 is simple: you call signInWithPopup or reauthenticateWithPopup, the user closes the Google window, and the promise only rejects with auth/popup-closed-by-user several seconds later. The reporter, on Firebase SDK 10.8.1 and Chrome 122, described it as "Consistently 7 or 8 seconds". Until then, the spinner keeps spinning and the login button stays disabled.
TL;DR#
- Cause: the SDK checks the popup every 2 seconds on desktop. When it sees the window closed, it does not reject straight away. It starts a fixed 8000 ms timer (
_Timeout.AUTH_EVENT) and only rejects when that timer fires. - Why the wait exists: in some flows the auth handler closes the popup itself, and the sign-in result is still on its way. Blocking functions can take up to 7 seconds.
- Fix: do not wait for the rejection to reset your UI. Watch the popup yourself, put the button back as soon as it closes, and keep the original promise alive so a late success still lands.
- Not fixed upstream: the issue is still open, and the 8000 ms constant is unchanged in firebase 13.0.0 and on the main branch.
The rejection, as caught from a real signInWithPopup call against the Firebase Auth emulator:
before #1: popup closed, rejection after 9273 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).Why it happens#
The behaviour lives in the popup strategy, packages/auth/src/platform_browser/strategies/popup.ts at the firebase@13.0.0 tag. Two constants drive it:
export const enum _Timeout {
AUTH_EVENT = 8000
}
export const _POLL_WINDOW_CLOSE_TIMEOUT = new Delay(2000, 10000);And the poll that uses them. The method is verbatim from the same file; only the class shell around it is cut down:
class PopupOperation extends AbstractPopupRedirectOperation {
// fields, constructor, executeNotNull, onExecution, eventId, cancel and cleanUp omitted
private pollUserCancellation(): void {
const poll = (): void => {
if (this.authWindow?.window?.closed) {
// Make sure that there is sufficient time for whatever action to
// complete. The window could have closed but the sign in network
// call could still be in flight. This is specifically true for
// Firefox or if the opener is in an iframe, in which case the oauth
// helper closes the popup.
this.pollId = window.setTimeout(() => {
this.pollId = null;
this.reject(
_createError(this.auth, AuthErrorCode.POPUP_CLOSED_BY_USER)
);
}, _Timeout.AUTH_EVENT);
return;
}
this.pollId = window.setTimeout(poll, _POLL_WINDOW_CLOSE_TIMEOUT.get());
};
poll();
}
}The Delay class, in core/util/delay.ts, returns the short value (2000 ms) in a browser and the long one (10000 ms) only when it detects Cordova or React Native. When the browser reports itself offline it picks the shorter of 5000 ms and the short value. So on desktop and mobile web you get up to 2 seconds before the SDK notices the window is gone, then a fixed 8 seconds before it rejects. That makes 8 to 10 seconds in total, depending on where in the 2 second cycle the user closed the window.
The 8 seconds is a deliberate trade-off. Until April 2023 the second timer was 2000 ms. Commit 1d6771e (PR #7140) raised it to 8000 ms, with this changeset line: "Increase the popup poller timeout to 8s to support blocking functions + Firefox". The commit message explains the case. In Firefox, on mobile, or when the opener is an iframe, the OAuth helper closes the popup right after the provider signs the user in. The SDK still has to process the provider's response and call the sign-in API, and that "can take upto 7s, if there is a blocking function configured". Firebase's blocking functions documentation sets that limit: "Your function must respond within 7 seconds."
The change came from issue #6956, "signInWithPopup issue with Firefox and beforeSignIn blocking function". There, a contributor to the SDK described the race: the auth handler "sends an AuthEvent and closes the popup", while the main window listens for the event and polls for cancellation, "so if the poller fires before the AuthEvent is processed, then we can end up in error". With a 2 second timer, a slow beforeSignIn function produced a false popup-closed-by-user for users who had in fact signed in. With 8 seconds, every user who really cancels waits for it.
The version history is checkable on the tags. At @firebase/auth@0.23.0 the constant is 2000. At @firebase/auth@0.23.1 it is 8000. In the firebase package's registry document, firebase 9.20.0 (18 April 2023) depends on @firebase/auth 0.23.0, and the next stable release, firebase 9.21.0 (27 April 2023), depends on 0.23.1. Checked on 19 October 2026, the constant is still 8000 in firebase 13.0.0 and on the main branch, and the issue is still open. A repository contributor on the thread wrote "I was able to reproduce the behavior now" in March 2024. A GitHub search of the repository finds no pull request that references it.
Fix#
The rejection is slow, but window.closed is not. The fix keeps the SDK's behaviour (so slow, legitimate sign-ins still succeed) and separates "the popup is gone, show the button again" from "the sign-in failed".
- Capture the popup reference when the SDK opens it, and watch
closedyourself. This module wraps onesignInWithPopupcall. It restoreswindow.openas soon as the SDK has used it, so otherwindow.opencalls on the page are not captured.
import { signInWithPopup } from 'firebase/auth';
export function signInWithPopupFastClose(auth, provider, onPopupClosed) {
let popup = null;
const originalOpen = window.open;
window.open = function (...args) {
window.open = originalOpen;
popup = originalOpen.apply(window, args);
return popup;
};
const watcher = setInterval(() => {
if (popup && popup.closed) {
clearInterval(watcher);
onPopupClosed();
}
}, 250);
return signInWithPopup(auth, provider).finally(() => {
clearInterval(watcher);
window.open = originalOpen;
});
}- Reset the UI in the callback, and treat the promise as the final word. A closed popup means "idle again", not "failed". If the handler closed the popup itself and the result arrives afterwards, the promise still resolves and the user is signed in.
That late success has one trap. The SDK allows a single popup operation at a time: the PopupOperation constructor in popup.ts cancels any operation still in progress, and that rejects the earlier promise with auth/cancelled-popup-request (core/errors.ts). If the button comes back while a blocking function is still finishing the first sign-in, a second click would throw that sign-in away. So keep a pending flag: the button looks idle again, but signInWithPopup is not called a second time until the first promise has settled.
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { signInWithPopupFastClose } from './sign-in-fast-close.js';
const auth = getAuth();
const button = document.querySelector('#google-sign-in');
let pending = null;
button.addEventListener('click', async () => {
if (pending) {
// The previous sign-in may still complete; do not cancel it.
return;
}
button.disabled = true;
pending = signInWithPopupFastClose(auth, new GoogleAuthProvider(), () => {
button.disabled = false;
});
try {
const cred = await pending;
console.log('signed in', cred.user.uid);
} catch (e) {
if (e.code !== 'auth/popup-closed-by-user' && e.code !== 'auth/cancelled-popup-request') {
console.error(e);
}
} finally {
pending = null;
button.disabled = false;
}
});The flag means a click in the few seconds after the popup closes does nothing. That is the cost of not cancelling a sign-in that may still succeed. The guard logic was not part of the timed reproduction below.
- If your app also listens with
onAuthStateChanged, let that listener drive the signed-in state. A late success then updates the page even if the user has already clicked elsewhere.
The workaround posted on the issue does something else. It replaces window.setTimeout globally and turns any 8000 ms timer into 2000 ms (one variant uses 1000 ms):
(function () {
const originalSetTimeout = window.setTimeout;
window.setTimeout = function (fn, delay, ...args) {
if (delay === 8000) {
delay = 2000;
}
return originalSetTimeout(fn, delay, ...args);
};
})();Its author labels it "use at your own risk". This override was not run in the reproduction below, which edited the compiled constant instead, but the risk follows from the commit that set 8000 ms. Both conditions have to hold: the auth helper closes the popup itself (in Firefox, on mobile, or with an iframe opener), and processing the sign-in, typically a beforeSignIn blocking function, takes longer than the shortened wait. The user then gets popup-closed-by-user for a sign-in that would have succeeded. It also changes every unrelated 8000 ms timer on the page. In the React useEffect version, each mount wraps the previous wrapper again. The close watcher above changes nothing inside the SDK.
If no popup is the better trade for your product, signInWithRedirect has no popup to poll. It has its own constraints on browsers that block third-party storage, listed in Firebase's redirect best practices.
Verify the fix#
The timings below were reproduced on 19 October 2026 inside the mcr.microsoft.com/playwright:v1.63.0-noble image, with firebase 13.0.0 (which bundles @firebase/auth 1.13.7), firebase-tools 15.34.0 and its Auth emulator, and headless Chromium 153. A page called signInWithPopup with a GoogleAuthProvider, Playwright closed the emulator's popup 1.5 seconds after it loaded, and the page recorded when the promise rejected. Three closes per run.
The unmodified SDK, with the UI waiting for the rejection:
$ node run.mjs before
chromium 153.0.8010.12
before #1: popup closed, rejection after 9273 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).
before #2: popup closed, rejection after 8421 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).
before #3: popup closed, rejection after 8402 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).The same SDK, with the watcher logic from step 1 (capture the window, check closed every 250 ms) resetting the UI:
$ node run.mjs after
chromium 153.0.8010.12
after #1: popup closed, rejection after 9143 ms, UI reset after 45 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).
after #2: popup closed, rejection after 8434 ms, UI reset after 165 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).
after #3: popup closed, rejection after 8452 ms, UI reset after 191 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).The UI came back in under 200 ms. The SDK's rejection still arrived between 8434 and 9143 ms after the close, as in the first run, which shows the watcher changes only what the user sees, not the SDK's decision. As a control, a third run changed one thing: the built SDK's timer, edited from 8000 to 2000 ms in node_modules for the test only:
$ node run.mjs before
chromium 153.0.8010.12
before #1: popup closed, rejection after 3438 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).
before #2: popup closed, rejection after 2401 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).
before #3: popup closed, rejection after 2425 ms
code=auth/popup-closed-by-user message=Firebase: Error (auth/popup-closed-by-user).The rejection dropped by about 6 seconds, the size of the change. That confirms this timer, and not the emulator or the browser, accounts for the wait. To check your own app, log Date.now() in the close callback and in the catch. The callback should fire within your interval (250 ms here), and the catch should still arrive about 8 seconds later.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Where the close watcher has nothing to watch#
The watcher reads popup.closed, the same property the SDK polls, so whatever your security headers do to the SDK's read also happens to yours. If the console shows Cross-Origin-Opener-Policy policy would block the window.closed call and the promise hangs instead of rejecting late, the problem is a header, not the timer. It is covered in the COOP window.closed fix, which relaxes the header to same-origin-allow-popups on the route that opens the popup.
The popup helper, util/popup.ts at firebase@13.0.0, has one path that never calls window.open. When the page runs as an iOS home-screen web app, the SDK opens the provider page by dispatching a click on a generated link and keeps no window reference. Your capture never fires there, and neither does the SDK's own close poll, so both stay silent if the user backs out.
The reproduction covers the client SDK's timer only. It ran against the Firebase Auth emulator's sign-in page, not Google's real consent screen or Firebase's hosted auth handler. The timer runs in the browser in both cases, but the network leg of a real Google sign-in was not part of the test.
Two Firebase Auth errors nearby#
If you test against the Auth emulator as this page did, the client still needs a non-empty apiKey, which is explained in auth/invalid-api-key without a real apiKey. If App Check and Auth both use reCAPTCHA Enterprise on the same page, Invalid site key or not loaded in api.js is a different Firebase Auth failure, fixed by upgrading the SDK.
The 8 seconds is not a bug in your app, and you cannot configure it through the SDK. It is a safety margin for sign-ins that finish after the popup has closed. Until issue #8061 changes that, the honest fix is in your UI: notice the closed window yourself, give the user their button back, and let the SDK's promise settle in its own time.
Further reading: linkWithRedirect fails with the Auth emulator: 3 causes. Also see useEmulator disableWarnings: TS2554 Expected 1 arguments.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
linkWithRedirect fails with the Auth emulator: 3 causes
Three different errors hide behind one symptom, and only one of them is about the emulator. Two were reproduced with linkWithRedirect against the emulator, the API key one with the emulator disconnected.
auth/invalid-api-key still throws without a real apiKey
The rejection happens one function call earlier than most people expect, before any server, project or emulator is ever reached.
Fix "c(...) is not a function" on Firebase SSR deploy
A Stack Overflow question with 14 votes and no accepted answer, and the GitHub issue the same reporter filed a month later with minification turned off. Together they show exactly which import fails, and that this bug was never really fixed — only avoided.


