Firebase Auth IDBDatabase Transaction Error: 2026 Fix
Firebase Auth stores its session in an IndexedDB database called firebaseLocalStorageDb. When that connection closes mid-session, sign-in throws InvalidStateError. Here is the cause, the SDK version that changed the behaviour, and a Next.js App Router setup that degrades gracefully.
InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing. is the error users of Firebase Auth reported for well over a year in firebase/firebase-js-sdk issue #1926. The thread opened in June 2019 against SDK 6.2.2, collected 94 comments from Chrome, Safari and Chrome OS users, and was closed in November 2020 when the auth package gained retry logic. The pattern in the reports is consistent: the network call to securetoken.googleapis.com or identitytoolkit succeeds, the user is created or signed in on Google's side, and then the SDK throws a DOMException while trying to write the session to IndexedDB. The client never learns it is signed in. Several commenters describe support tickets where the account exists in the Firebase console but the app's own user record was never created because the promise rejected first.
If you are on a modern firebase release the crash itself is largely handled, but the underlying failure is still real and still surfaces in Sentry as a rejected sign-in. This post explains what the database is, why the connection closes, what changed in the SDK, and how to configure Firebase Auth in a Next.js App Router app so that a broken IndexedDB degrades to localStorage instead of failing the login.
What the error actually means#
Firebase Auth's browser persistence layer stores the current user in an IndexedDB database called firebaseLocalStorageDb, with a single object store named firebaseLocalStorage. Every read or write of the session opens a transaction on that store. The constants are still exactly that in the current SDK source (packages/auth/src/platform_browser/persistence/indexed_db.ts).
The browser throws InvalidStateError from IDBDatabase.transaction() when the connection it is called on is closing or closed. The SDK holds one open connection for the lifetime of the page, so anything that closes that connection underneath it turns the next token refresh or sign-in into this exception. The thread identifies several triggers:
- Site data cleared while the tab is open. Multiple commenters reproduce it at will: load the page, press "Clear site data" in DevTools (or delete
firebaseLocalStorageDbfrom the Application tab, or clear cookies for the site), then callsignInWithPopuporcreateUserWithEmailAndPasswordwithout reloading. It fails every time until the page is refreshed. - WebKit closing the connection after inactivity. The bulk of the production reports are Mobile Safari 13 and 14. A Firebase engineer on the thread links WebKit bug 197050, where Safari's IndexedDB server drops connections that the page still believes are open. One commenter with 142 Sentry events on iOS 13.3.1 saw nothing on other platforms.
- A second app writing to IndexedDB on the same origin. One report notes the error became more frequent when
localforagewas also using IndexedDB, and dropped when it was moved off it. Two Firebase app instances, or two SDK versions loaded on the same origin, share the same database name and version, so a schema mismatch or a competingdeleteDatabasefrom one of them hits the other. - Deploys. One team reports the error appears on their live site after every new deployment, which is the "storage changed under a long-lived tab" case seen from the other side.
Private browsing is worth mentioning because it comes up in search results, but it is a different failure: browsers that refuse to open IndexedDB at all cause the SDK to fall back at initialisation, not to throw mid-session. The mid-session InvalidStateError is about a connection that was open and then was not.
Which SDK versions were affected and what changed#
The issue reproduces on the compat SDK from 6.x through 8.0.1. The maintainer's closing comment says "the next release will retry these errors like Firestore does". That is PR #4059, released in @firebase/auth 0.15.2, bundled in firebase 8.0.2 (November 2020), with the changelog line "Retry IndexedDB errors a fixed number of times to handle connection issues in mobile webkit." A follow-up, PR #4146 in firebase 8.1.2, fixed the retry logic itself throwing uncaught errors.
The modular SDK (v9 onwards, currently 12.x) carries the same design forward. Reading the current source: every persistence operation goes through _withRetries, which reopens the database and retries up to _TRANSACTION_RETRY_COUNT = 3 times before rethrowing. If the object store is missing when the database opens, the SDK deletes and recreates the database itself. So on a current release the error you see is the one that survived three reconnect attempts, which in practice means Safari has genuinely killed IndexedDB for that origin until the tab or browser is restarted. That matches the thread's field reports: a plain reload usually works on Chrome, while on iOS some users needed to close Safari entirely.
Two consequences for anyone still seeing this in 2026:
- If your Sentry stack trace mentions
auth.esm.jsorfirebase-auth-*.jsfrom a v6/v7 bundle, the fix is to upgrade. Everything below 8.0.2 has no retry at all. - If you are on v10/v11/v12 and still see it, the SDK has already retried. Your job is to make the failure non-fatal: fall back to another persistence and give the user a working path.
Fix 1: initialise Auth with a persistence fallback array#
getAuth(app) in the browser is a thin wrapper that calls initializeAuth with persistence: [indexedDBLocalPersistence, browserLocalPersistence, browserSessionPersistence]. The Dependencies.persistence reference documents the array semantics: "the first Persistence that the device supports is used", and if none is provided the SDK falls back to inMemoryPersistence. Availability is checked by writing and deleting a probe key, so an IndexedDB that fails at page load is skipped and localStorage takes over.
The catch is that this probe happens once, at initialisation. It protects you from a broken IndexedDB at load time, not from one that closes twenty minutes later. Still, explicit initialisation is the right foundation, because it lets you drop IndexedDB entirely if your error budget says so, and it guarantees the auth instance is never constructed during server rendering.
// lib/firebase/client.ts
import { getApp, getApps, initializeApp, type FirebaseApp } from 'firebase/app';
import {
browserLocalPersistence,
browserPopupRedirectResolver,
browserSessionPersistence,
indexedDBLocalPersistence,
initializeAuth,
type Auth,
} from 'firebase/auth';
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
let auth: Auth | undefined;
export function getFirebaseApp(): FirebaseApp {
return getApps().length ? getApp() : initializeApp(firebaseConfig);
}
export function getClientAuth(): Auth {
if (typeof window === 'undefined') {
throw new Error('getClientAuth() must only be called in the browser');
}
if (!auth) {
auth = initializeAuth(getFirebaseApp(), {
persistence: [
indexedDBLocalPersistence,
browserLocalPersistence,
browserSessionPersistence,
],
popupRedirectResolver: browserPopupRedirectResolver,
});
}
return auth;
}Three details matter here. The module-level cache exists because initializeAuth throws auth/already-initialized if it is called twice for the same app, which React Fast Refresh will happily do. The typeof window guard is the App Router equivalent of the check described in Window is not defined in Next.js: a 'use client' component is still rendered once on the server, and indexedDB does not exist there. And popupRedirectResolver is required if you use signInWithPopup; if you only use email/password you can omit it and shave the bundle, exactly as the reference suggests.
If you decide IndexedDB is not worth the Safari risk for your audience, reorder the array to [browserLocalPersistence, browserSessionPersistence]. You lose cross-tab sync through IndexedDB and the service-worker access that IndexedDB provides, but localStorage has never produced this particular error.
Fix 2: catch the error at the call site and recover#
Because the SDK retries internally, by the time InvalidStateError reaches your code the connection is gone for this page. Detect it, give the user a clean recovery, and log it with the browser so you can see whether it is a Safari-only problem.
// lib/firebase/is-idb-error.ts
export function isIndexedDbConnectionError(err: unknown): boolean {
if (typeof DOMException === 'undefined' || !(err instanceof DOMException)) {
return false;
}
return err.name === 'InvalidStateError' && err.message.includes('IDBDatabase');
}// app/(auth)/sign-in/sign-in-form.tsx
'use client';
import { signInWithEmailAndPassword } from 'firebase/auth';
import { useState } from 'react';
import { getClientAuth } from '@/lib/firebase/client';
import { isIndexedDbConnectionError } from '@/lib/firebase/is-idb-error';
const AUTH_DB_NAME = 'firebaseLocalStorageDb';
export function SignInForm() {
const [error, setError] = useState<string | null>(null);
async function onSubmit(formData: FormData) {
const email = String(formData.get('email'));
const password = String(formData.get('password'));
try {
await signInWithEmailAndPassword(getClientAuth(), email, password);
} catch (err) {
if (isIndexedDbConnectionError(err)) {
// The IndexedDB connection is dead for this page. Drop the auth
// database so the next load recreates it, then reload.
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(AUTH_DB_NAME);
req.onsuccess = req.onerror = req.onblocked = () => resolve();
});
window.location.reload();
return;
}
setError('Sign-in failed. Please try again.');
}
}
return (
<form action={onSubmit}>
<input name="email" type="email" autoComplete="email" required />
<input name="password" type="password" autoComplete="current-password" required />
<button type="submit">Sign in</button>
{error ? <p role="alert">{error}</p> : null}
</form>
);
}The reload is the workaround that the thread converged on, and it is not elegant. The reason it is acceptable: the Google-side sign-in already succeeded, so after the reload the SDK reopens firebaseLocalStorageDb, finds nothing, and the user simply signs in again with a working store. Deleting the database first covers the case where a stale, versioned database from a previous deployment is what caused the connection to close; the SDK's own _openDatabase only self-heals when the object store is missing, not when the connection is merely stale.
Two things not to do. Do not wrap this in an infinite retry loop on the client: one commenter describes users on Safari stuck seeing "SMS code has expired" and the IDB error alternately until they gave up. And do not create your own user record before the auth promise resolves; the reports of "account exists in Firebase but not in our database" come from apps that ran two writes with no idempotency, which is the same class of problem as the trigger failures in Supabase "Database error saving new user".
Fix 3: know when the popup, not the database, is the problem#
If signInWithPopup hangs rather than throws, you are looking at a different bug. A Cross-Origin-Opener-Policy: same-origin header on the sign-in page prevents the SDK from polling window.closed, and the promise never settles. That one is covered in Cross-Origin-Opener-Policy blocks window.closed. The IndexedDB error is loud and immediate; the COOP one is silent.
Debugging checklist#
- Open DevTools, Application tab, IndexedDB. You should see
firebaseLocalStorageDbwith one store,firebaseLocalStorage, and one key of the formfirebase:authUser:<apiKey>:[DEFAULT]. If the store is missing but the database exists, an older or foreign build created it. - Check how many Firebase app instances are on the page.
getApps().lengthin the console should be 1. A second instance from a widget or an old CDN<script>shares the same database. - Filter your error tracker by browser. If it is almost entirely Mobile Safari, you are in WebKit bug 197050 territory and the persistence array reorder is the pragmatic answer.
- Confirm the installed version:
npm ls firebase. Anything below 8.0.2 has no retry logic. - Reproduce deliberately: load the page, clear site data in DevTools, sign in without reloading. If your catch block does not handle it gracefully, that is what users on a fresh deployment see.
The Supabase equivalent#
Supabase's browser client stores its session in localStorage, and with @supabase/ssr in Next.js the session lives in cookies that both the server and the browser read. There is no IndexedDB in the path, so this class of error does not exist; the failure modes are different and are about cookie refresh timing, which is what Supabase auth session disappears on refresh and the Supabase SSR sessions guide cover. If IndexedDB reliability on iOS is part of why you are evaluating alternatives, Supabase vs Firebase Authentication compares the two session models directly.
Related#
- Cross-Origin-Opener-Policy Blocks window.closed: 2026 Fix
- Firestore PERMISSION_DENIED: Every Real Cause and Fix
- Window is not defined in Next.js
- Supabase vs Firebase Authentication: Which is Better
References#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Fix "Default condition should be last one" (Firebase)
One Firebase release put "default" before "browser" in the package.json exports map of 30 packages, and webpack's resolver refuses that. Here is the exact error, the diff that caused it, the release that fixed it, and how to find any other package in node_modules doing the same thing.
FirebaseError: Expected first argument to collection() — Fix
When Firestore throws 'Expected first argument to collection()', it's almost always a missing Firestore instance. Learn the exact fix for Next.js and v9 modular SDK.
Fix: Next.js proxy.js matcher not working for static assets
Without a matcher, proxy.js runs on every request including _next/static and public assets. And Server Functions are not separate routes — a matcher gap silently drops auth coverage.
Browse by Topic
Find stories that matter to you.
