instant client · indexeddb lock investigation

Why a frozen tab can block Instant auth

One tab is saving pending mutations. Android freezes it at the wrong moment. Another tab tries to load the current user and waits on the first tab's database lock. This is the mechanism and the smallest useful fix.

01The page and the database manager are separate

Chrome does not run everything for a tab in one place. The tab's code runs in a renderer process. Think of that as the worker responsible for the page: it runs JavaScript, timers, and browser API callbacks.

The actual IndexedDB engine lives in Chrome's shared browser process. Think of that as the database manager. It owns the data on disk and decides which tab holds each database lock. The page worker and database manager communicate by sending messages to each other.

renderer process: tab A V8 · DOM · event loop sandboxed, no disk access renderer process: tab B V8 · DOM · event loop sandboxed, no disk access browser process (one per Chrome) IndexedDB backend, shared by origin transaction scheduler lock queue per object store on-disk store IDB calls + results (async IPC) IDB calls + results
The important fact is simple: the tabs have separate page workers, but they share one database manager and one set of locks.

02One local write requires a round trip

Instant calls store.put(value). The page worker first serializes the value, then sends it to the database manager. The database manager performs the write and sends a result back to the page worker. IndexedDB normally waits for that result to be processed before deciding that the transaction is finished.

Why wait? A result handler is allowed to add another write to the same transaction. Until the page worker processes the result, IndexedDB cannot assume that no more work is coming.

03Automatic commit depends on the writer tab

A readwrite transaction gets exclusive access to the object stores it touches. Later transactions that need the same store, including transactions from another tab, wait behind it.

With normal automatic commit, the database manager waits for the writer tab to process its request results. Only then does the page worker send the final message that no requests remain. The database manager commits the transaction and releases the lock.

That creates the dangerous dependency: releasing a shared database lock can require one particular tab to run again.

04A freeze turns that dependency into a cross-tab stall

Android can suspend a background tab's page worker at an arbitrary instruction. The tab has not closed, so Chrome still has a live database transaction associated with it. But the tab is no longer processing the result that normally leads to automatic commit.

Current Chromium has an internal recovery watchdog for some stalled, blocking transactions. In one local run it released the sibling tab after about 60 seconds. That watchdog is heuristic, browser-specific, and not a timeout Instant can configure or depend on. The user can still experience a long auth stall, and some freeze points appear to fall outside the watchdog's useful state.

Instant's client makes the window easier to hit under write load. A dirty local value schedules persistence after a 100ms throttle, followed by a bounded idle callback. This only batches local persistence; each db.transact() is still sent to the server independently. When persistence runs, the whole pendingMutations map is rewritten into the kv store. Then a new tab boots, and subscribeAuth waits on a read of currentUser from that same store:

lock queue: object store "kv" holder tab · readwrite frozen before commit message booting tab · readonly get("currentUser") waits holds the store subscribeAuth is awaiting this read, so boot spins. The read starts when the writer commits or aborts.
The second tab is healthy. Its auth read simply cannot start while the frozen writer owns the same object-store lock.

05Explicit commit removes the largest waiting window

IDBTransaction.commit() tells the database manager: all requests have already been queued; no more are coming. Instant knows this is true because multiSet() adds every put() synchronously in one loop.

Once that commit message has been sent, the database manager can finish the queued writes and release the lock without waiting for the writer tab to process each result first.

auto-commit (today) with tx.commit() writer renderer backend writer renderer backend tx open put() issued executes put, sends success put (IPC) FROZEN success (IPC) onsuccess never dispatches waiting for renderer: "any more requests?" no auto-commit sibling waits tx open put() issued commit() called executes put, no reply needed put + commit (IPC) FROZEN commits on its own lock released events delivered whenever tab thaws
The freeze is identical in both panels. The only difference is one message sent before the freeze: “nothing more is coming.”

The change in IndexedDBStorage is small because every method already issues all its requests up front:

const tx = db.transaction(['kv'], 'readwrite');
const store = tx.objectStore('kv');
for (const [k, v] of pairs) store.put(v, k);
tx.commit?.(); // backend may now commit without us

06What remains after the small fix

Explicit commit does not eliminate every possible freeze point. The value still has to be serialized in the page worker, sent to the database manager, and followed by the commit call. Android could freeze the tab before it reaches that line.

That gives us a staged plan. First, add explicit commit because it is a small change that removes the largest known wait. Second, stop rewriting one ever-growing mutation value, so serialization stays small. Third, isolate auth from mutation persistence so a stuck mutation writer does not own the resource auth needs.

A timeout in the active tab can stop the UI from waiting forever, but it cannot release a lock owned by the frozen tab. Only Chrome's database manager can abort that transaction.