firebase / firebase/firebase-js-sdk

[auth] IndexedDB persistence produces unhandled promise rejections when the browser deletes the database (floating promises in `startPolling` / `_addListener`)

Open
#10,382 0 comments 0 reactions 0 assignees View on GitHub
api: auth needs-attention stack:React
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.