firebase / firebase/firebase-js-sdk
[auth] IndexedDB persistence produces unhandled promise rejections when the browser deletes the database (floating promises in `startPolling` / `_addListener`)
- Dominant language
- TypeScript
- Stars
- 5.1k
- Forks
- 1k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 37
Description
### Operating System
macOS 15 (Safari), also reproducible in Chromium with a synthetic IndexedDB fault injector.
### Browser Version
Safari 26.6.1 (observed in production telemetry); Chromium 141 (synthetic reproduction).
### Firebase SDK Version
`firebase` 11.10.0 (`@firebase/auth` 1.10.8). The code path is unchanged on `main` at the time of writing.
### Firebase SDK Product
Authentication
### Describe your project's tooling
React 18 + Vite 5 web app, ES modules, bundled with Rollup. Sentry browser SDK installed with the default `globalHandlers` integration, so `window.onunhandledrejection` is reported.
### Describe the problem
`IndexedDBLocalPersistence` starts a poller and a listener whose promises are never owned by anything:
`packages/auth/src/platform_browser/persistence/indexed_db.ts`
```ts
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(async () => this._poll(), _POLLING_INTERVAL_MS);
}
```
```ts
_addListener(key: string, listener: StorageEventListener): void {
if (this.listeners.size === 0) {
this.startPolling();
}
...
// the initial read
void this._get(key);
}
```
The `setInterval` callback is an `async` function, so its returned promise is discarded; `void this._get(key)` discards its promise explicitly. Both funnel through `_withRetries`, which gives up after 5 attempts:
```ts
async function _withRetries(op: (db: IDBDatabase) => IDBRequest | IDBTransaction): Promise {
let numAttempts = 0;
while (true) {
try {
const db = await _openDatabase();
...
} catch (e) {
if (numAttempts++ > 3) { throw e; }
...
}
}
}
```
When the browser deletes the origin's IndexedDB **while the page is running**, every pending open and every live transaction fails immediately. On WebKit that is `UniqueIDBDatabase::immediateClose()` (`Source/WebCore/Modules/indexeddb/shared/UniqueIDBDatabase.cpp`), reached from website-data deletion: Safari's *Manage Website Data > Remove*, *Clear History* for a time range, or an Intelligent Tracking Prevention data-records pass. It aborts in-progress transactions and fails pending open requests with `IDBError::userDeleteError()`, a `DOMException` named `UnknownError` with the message `Database deleted by request of the user`.
Because the five `_withRetries` attempts have no backoff, they all run inside a few milliseconds and are still inside the deletion window, so the retry loop rethrows, out of a discarded promise. The result is an **unhandled promise rejection with no stack and no preceding console output**, once per 800 ms poll for as long as the deletion takes.
For an application that reports unhandled rejections, this is a recurring error report that the application cannot prevent, cannot handle, and did not cause. It is also silent: unlike `packages/app`'s heartbeat reader, which logs `Error thrown when reading from IndexedDB` before its own floating `tx.done` rejects, these two paths produce no console line at all, so there is nothing in the report to identify them by.
A third, related site is `initializeCurrentUser` -> `getCurrentUser()` in `packages/auth/src/core/auth/auth_impl.ts`, which has no catch. A rejection there rejects `_initializationPromise`, which is floated by `_initializeAuthInstance` and by `registerStateListener`'s `promise.then(cb)` (no rejection handler), and in that case `onAuthStateChanged` never fires at all.
Related: #8593 (`Firestore indexDB persistence corrupted after user "Clear site data"`) is the Firestore cousin of the same deletion race.
### Steps and code to reproduce the issue
The deletion itself is only reproducible on WebKit, so the repro below injects the JS-visible *effect* of `immediateClose()`, pending opens error, live transactions abort, both carrying the same `DOMException`, which runs anywhere.
```js
// Install BEFORE any Firebase import.
const DELETED = () =>
new DOMException('Database deleted by request of the user', 'UnknownError');
let faultUntil = 0;
const realOpen = indexedDB.open.bind(indexedDB);
indexedDB.open = (...args) => {
const request = realOpen(...args);
if (Date.now() < faultUntil) {
setTimeout(() => {
Object.defineProperty(request, 'error', { value: DELETED(), configurable: true });
request.dispatchEvent(new Event('error'));
}, 0);
}
return request;
};
const realGetAll = IDBObjectStore.prototype.getAll;
IDBObjectStore.prototype.getAll = function (...args) {
const request = realGetAll.apply(this, args);
if (Date.now() < faultUntil) {
const tx = this.transaction;
setTimeout(() => {
Object.defineProperty(request, 'error', { value: DELETED(), configurable: true });
request.dispatchEvent(new Event('error'));
try { tx.abort(); } catch { /* already aborted */ }
}, 0);
}
return request;
};
window.addEventListener('unhandledrejection', (e) => {
console.log('UNHANDLED', e.reason && e.reason.name, e.reason && e.reason.message);
});
// --- app code ---
import { initializeApp } from 'firebase/app';
import { getAuth, onAuthStateChanged } from 'firebase/auth';
const auth = getAuth(initializeApp({ /* any config */ }));
onAuthStateChanged(auth, () => {});
// Let Auth settle and the 800 ms poller start, then open a 3 s fault window,
// i.e. the user deletes this site's data while the tab is open.
setTimeout(() => { faultUntil = Date.now() + 3000; }, 3000);
```
**Expected:** the SDK tolerates the deletion. The poller skips the ticks it cannot serve and resumes when storage is available again (it already does resume, see below); nothing reaches `window.onunhandledrejection`.
**Actual:** one unhandled rejection per poll for the duration of the window, with no console output.
```
UNHANDLED UnknownError Database deleted by request of the user (t = 3206 ms)
UNHANDLED UnknownError Database deleted by request of the user (t = 4006 ms)
UNHANDLED UnknownError Database deleted by request of the user (t = 4806 ms)
UNHANDLED UnknownError Database deleted by request of the user (t = 5606 ms)
```
Polling resumes normally after the window, which is the useful part: the *recovery* is already correct; only the reporting is wrong.
Two control cases, for scoping:
- a single aborted `getAll` on `firebaseLocalStorageDb` (not a sustained window) produces **zero** unhandled rejections, `_withRetries` reopens and succeeds;
- a fault window that covers boot hits the `_isAvailable` probe first, and the SDK falls back to `browserLocalPersistence` for the session with no rejection. Whether the probe or the 5 retries are hit first depends purely on when the deletion lands.
The production event that prompted this: `onunhandledrejection`, `DOMException.code: 0`, title `Error: UnknownError: Database deleted by request of the user`, no frames, Safari 26.6.1.
### Suggested fix
The recovery logic is right; only the promise ownership is wrong. Three small changes:
```ts
// startPolling: the interval callback's promise has no owner.
this.pollTimer = setInterval(() => {
void this._poll().catch(() => {
// The database is momentarily unavailable (site-data deletion, quota,
// private-mode restrictions). The next tick re-opens it; nothing here is
// actionable by the application.
});
}, _POLLING_INTERVAL_MS);
```
```ts
// _addListener: the eager read is explicitly floated.
void this._get(key).catch(() => {
// Same reasoning: the poller will produce the value once storage returns.
});
```
```ts
// initializeCurrentUser -> getCurrentUser(): let a storage failure degrade to
// "no persisted user" instead of rejecting _initializationPromise, which is
// itself floated and, when it rejects, stops onAuthStateChanged from ever
// firing.
const storedUser = await this.assertedPersistence.getCurrentUser().catch(() => null);
```
If swallowing is considered too quiet, routing these through the existing `_logWarn` would at least give applications something identifiable in the report, but a `console.warn` per 800 ms tick during a deletion window is its own noise, so a silent catch on the poller plus one warning on the initial read seems the better balance.
Happy to open a PR if the maintainers agree on the shape.
Contributor guide
Research direction
Start with packages/auth/src/platform_browser/persistence/indexed_db.ts, especially startPolling and _addListener, then inspect initializeCurrentUser in packages/auth/src/core/auth/auth_impl.ts. Run the supplied IndexedDB fault-injector reproduction and verify that deletion causes no unhandled rejection, polling resumes afterward, and onAuthStateChanged still completes initialization.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication, databases
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100