An IndexedDB readwrite transaction writes its first record fine, then throws TransactionInactiveError on a second write issued after an awaited fetch(). Why did the transaction die, and how should the code be restructured?
answer
- nobody holds the transaction open
- alive for this turn and its own callbacks
- the network cannot answer this turn
- the earlier write already landed
- complete fires on the transaction, not the request
basics
~20 sIndexedDB transactions auto-commit: one stays active only while the task that created it, or one of its own request callbacks, is running. Awaiting a network response returns control to the event loop with no pending request, so the transaction commits and closes before the second write is issued.
solid answer
~60 sAn `IDBTransaction` has no explicit "begin work" window you hold open. It is active for the turn in which it was created and inside its own request event handlers; once its outstanding requests have all settled and control returns to the event loop without a new request being issued, the browser commits it. `await fetch(...)` guarantees exactly that gap, so by the time the second `put()` runs the transaction is finished and the call throws `TransactionInactiveError`. The first write is already durably committed, which is the nasty part — you are left with a half-applied change, not a rollback. The fix is to do all I/O that is *not* IndexedDB before opening the transaction: fetch first, then open a `readwrite` transaction and issue every request in that same turn, or open a second transaction afterwards and accept that the two are independent units. Promise wrappers around IndexedDB work only because their promises settle within the same turn; a promise that needs a fresh task will always lose the transaction.
code
javascript · 23 lines// Broken: the transaction commits during the await.
async function brokenSave(db, id) {
const tx = db.transaction('orders', 'readwrite');
const store = tx.objectStore('orders');
store.put({ id, state: 'pending' }); // this one commits
const fresh = await fetch(`/api/order/${id}`).then((r) => r.json());
store.put(fresh); // TransactionInactiveError
}
// Fixed: all network work first, then one atomic transaction.
async function save(db, id) {
const fresh = await fetch(`/api/order/${id}`).then((r) => r.json());
const tx = db.transaction(['orders', 'outbox'], 'readwrite');
tx.objectStore('orders').put(fresh);
tx.objectStore('outbox').delete(id);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}go deeper
Know that IndexedDB transactions close themselves, so all the reads and writes you want in one transaction must be issued together rather than spread across awaits on other work.
Explain precisely when a transaction is active — its creating turn and its own request callbacks — why a network await falls outside that, and why every store must be named in the transaction's scope up front.
Diagnose the production symptom: half-applied state rather than a rollback, durability observed on the transaction's complete event, tx.abort() as the deliberate rollback, and readwrite scope serialising writers in creation order.
Own the write model — how the auto-commit rule shapes batching, retry, and an offline outbox, and what invariants you can and cannot promise given that a transaction can never span a network round trip.
## The rule IndexedDB transactions are short and self-closing. You do not begin and end them; you create one with `db.transaction(storeNames, mode)` and the browser decides when it is over. A transaction has a state — **active** or **inactive** — and requests may only be issued while it is active. A transaction is active in exactly two situations: during the turn in which it was created, and inside the `success` or `error` handler of one of its own requests. It becomes inactive as soon as control returns to the event loop, and once inactive with no outstanding requests, the browser **commits** it. Any request issued against an inactive transaction throws `TransactionInactiveError` synchronously. ```js const tx = db.transaction('orders', 'readwrite'); const store = tx.objectStore('orders'); store.put(a); // fine — same turn store.put(b); // fine — still the same turn tx.oncomplete = () => console.log('committed'); ``` That design is deliberate. Because a transaction cannot be held open across arbitrary waits, one tab cannot lock a store while it waits on a slow server, and the database cannot be wedged by a page that forgot to close something. ## Why the fetch kills it ```js const tx = db.transaction('orders', 'readwrite'); const store = tx.objectStore('orders'); store.put(local); // committed const res = await fetch('/api/order'); // control returns to the event loop store.put(await res.json()); // TransactionInactiveError ``` A network response cannot arrive in the turn that started the request. Control necessarily goes back to the event loop, the transaction has nothing pending, and it commits. The later `put` is a call against a finished transaction. The crucial detail for a production answer: **the first write is not rolled back**. A commit is a commit. You have not lost the transaction's work, you have narrowed the transaction to less than you intended, and the atomicity you thought you had is gone. A partially applied migration or a queue entry written without its companion record is the real-world damage. ## Why promise wrappers still work Libraries that wrap IndexedDB in promises appear to defy this, and do not. Their promises settle within the same turn's microtask checkpoint, so the next request is still issued while the transaction is active. Anything that needs a *fresh task* — a network response, a timer, a message from a worker, an image decode — is on the other side of the line and will always find the transaction closed. The distinction to state in an interview is not "await is bad" but "await something that cannot settle this turn is bad". ## How to restructure Order the work so IndexedDB is a tight block: ```js const payload = await fetch('/api/order').then((r) => r.json()); const tx = db.transaction(['orders', 'outbox'], 'readwrite'); tx.objectStore('orders').put(payload); tx.objectStore('outbox').delete(payload.id); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); ``` Three points fall out of that shape. Every store you will touch must be named when the transaction is created — reaching for a store outside the transaction's scope raises `NotFoundError`, and scope cannot be widened later. Completion is observed on the *transaction*, via `complete`/`error`/`abort`, not on the last request; a request succeeding does not mean the data is durable. And `tx.abort()` is the explicit escape hatch that rolls back everything the transaction did, which is how you turn a failed validation into a clean no-op. ## Modes, scope, and concurrency `readonly` is the default-safe mode and multiple readonly transactions over the same store run concurrently. `readwrite` transactions whose scopes overlap are serialised, and — importantly — they run **in the order they were created**, not the order their first request is issued. Creating a readwrite transaction over a hot store and then leaving it idle blocks later writers behind it. Asking for `readwrite` when you only read is a real, if small, throughput cost. Browsers also expose an explicit `transaction.commit()` to end a transaction as soon as you know you are done rather than waiting for the auto-commit check, which shaves latency on chatty write batches. It does not change any of the rules above. ## What people get wrong Assuming the failed write means nothing was written; blaming `await` in general instead of naming which awaits cross a task boundary; calling `store.get` on a store that was never in the transaction's scope; and treating a request's `success` event as proof of durability when only the transaction's `complete` event says so.
- If the second write fails, why is the first write still in the database?Because the transaction that contained it already committed — the failure happened against a different, already-finished transaction object. Atomicity in IndexedDB is scoped to a transaction, and the auto-commit silently shrank the transaction to just the first write. That is why the symptom to look for is half-applied state rather than a clean rollback.
- How do you know a write is actually durable — the request's success event, or something else?The transaction's `complete` event. A request firing `success` only means that request was processed inside the transaction; the transaction can still abort afterwards, taking that write with it. Resolve your application-level promise on `tx.oncomplete`, and reject on `onerror` and `onabort` reading `tx.error`.
- What does calling tx.abort() do, and when is it the right tool?It rolls back every change the transaction made and fires `abort` instead of `complete`. It is the right tool when a validation or a conflict check partway through a multi-store write tells you the whole unit is invalid — you get a clean no-op instead of having to compensate. An unhandled request error aborts the transaction implicitly for the same reason.
- Why does asking for readwrite when you only read cost anything?Readonly transactions over the same store run concurrently, while readwrite transactions with overlapping scope are serialised in creation order. A needlessly readwrite transaction queues behind, and ahead of, real writers, so read latency rises under load and a transaction created early but used late holds up everyone behind it.
saying these in an interview costs you the question
- Believing the failed request rolled the whole thing back
- Thinking await is always safe inside a transaction
- Treating request success as proof the data is durable
- Expecting to add a store to the transaction's scope later
- Assuming a transaction stays open until you close it