LEARNING OBJECTIVES ⌵
- Understand the 3 transaction modes:
readonly,readwrite, andversionchange. - Define multi-store transaction scopes for cross-collection atomic operations.
- Master the auto-commit mechanics of the JavaScript event loop and avoid the
TransactionInactiveErrortrap. - Handle explicit rollbacks via
transaction.abort(), error events, and durability configuration hints.
📖 The Mental Model & Story (Intuitive Foundation)
Imagine a high-stakes bank transfer between two bank accounts: transferring $500 from Alice to Bob.
You must perform two separate actions:
- Deduct $500 from Alice's account.
- Add $500 to Bob's account.
If the browser crashes or encounters an error immediately after Step 1, Alice loses $500 and Bob gets nothing. The bank's ledger is now corrupted.
A Transaction creates a protective, isolated bubble around these actions:
- Atomicity (All or Nothing): Either both operations succeed, or if anything fails, the entire transaction rolls back cleanly as if nothing ever happened.
- Consistency: Data integrity rules (unique keys, valid paths) are strictly validated before committing.
- Isolation: Other tabs or concurrent scripts cannot view intermediate states (Alice with -$500 before Bob receives it).
- Durability: Once the transaction completes (
oncomplete), changes are securely flushed to persistent disk storage.
Technical Deep Dive & Specifications
The 3 Transaction Modes
+----------------------------------------------------------------------------------------------------+
| INDEXEDDB TRANSACTION MODES |
+-------------------+--------------------+-----------------------+-----------------------------------+
| Mode | Permitted Actions | Concurrency / Locks | Invocation / Scope |
+-------------------+--------------------+-----------------------+-----------------------------------+
| `readonly` | `get`, `getAll`, | Multiple `readonly` | `db.transaction(stores, |
| | `count`, `cursor` | tx can run in parallel| 'readonly')` |
+-------------------+--------------------+-----------------------+-----------------------------------+
| `readwrite` | `add`, `put`, | Exclusive lock on the | `db.transaction(stores, |
| | `delete`, `clear`, | specified ObjectStores| 'readwrite')` |
| | plus all reads | (other tx must wait) | |
+-------------------+--------------------+-----------------------+-----------------------------------+
| `versionchange` | Create/delete | Exclusive global lock | Created automatically inside |
| | stores & indexes | across entire database| `request.onupgradeneeded` |
+-------------------+--------------------+-----------------------+-----------------------------------+
Multi-Store Scopes
A transaction can span across multiple ObjectStores by passing an array of store names:
// Creates an atomic transaction locking both 'accounts' and 'audit_trail'
const tx = db.transaction(['accounts', 'audit_trail'], 'readwrite');
const accountStore = tx.objectStore('accounts');
const auditStore = tx.objectStore('audit_trail');
accountStore.put(aliceUpdated);
accountStore.put(bobUpdated);
auditStore.add({ action: 'TRANSFER', amount: 500, date: new Date() });
The Microtask Auto-Commit Lifecycle
One of the most common pitfalls for JavaScript engineers working with IndexedDB is the Transaction Auto-Commit Mechanism.
+-----------------------------------------------------------------------------+
| JAVASCRIPT EVENT LOOP TURN |
+-----------------------------------------------------------------------------+
| |
| 1. Open Transaction: const tx = db.transaction('store', 'readwrite') |
| 2. Queue Request 1: store.put(recordA) |
| 3. Queue Request 2: store.put(recordB) |
| |
| -- Synchronous JS Execution completes -- |
| -- Microtask Queue Drains (Promises resolve) -- |
| |
| [Is there an active IDBRequest pending on tx?] |
| / \ |
| YES NO |
| | | |
| Keep Tx Open AUTO-COMMIT TRANSACTION! |
| Wait for I/O (tx.state becomes "finished") |
| |
+-----------------------------------------------------------------------------+
The Golden Rule of IndexedDB Transactions:
An IndexedDB transaction remains active only as long as newIDBRequestcalls are queued in continuous microtask turns. If you introduce an asynchronous gap that is not an IndexedDB request—such asawait fetch(),setTimeout(), orawait crypto.subtle.digest()—the event loop completes its turn with no pending IDB requests. The browser automatically commits and closes the transaction!If you subsequently call
store.put()after thatfetch(), the browser throws:TransactionInactiveError: Failed to execute 'put' on 'IDBObjectStore': The transaction has finished.
Transaction Durability Options
Modern IndexedDB specifications allow tuning write durability performance via the transaction options dictionary:
const tx = db.transaction('metrics', 'readwrite', {
durability: 'relaxed' // Options: 'default' | 'strict' | 'relaxed'
});
'strict': The browser will not fireoncompleteuntil the OS has physically flushed changes to non-volatile disk media (slower, zero data loss risk on power failure).'relaxed': The browser firesoncompleteonce data is written to the operating system write-buffer (higher throughput, ideal for high-frequency logs).
💻 Interactive Code Playground
Starter Code
Save this file as transactions.html and open it in your browser.
Line-by-Line Code Breakdown
- Line 73 (
const tx = db.transaction(['accounts', 'transfers'], 'readwrite')): Opens an atomic transaction spanning two ObjectStores simultaneously with exclusive locks. - Lines 77–90 (
accStore.put(...),transferStore.add(...)): Executes writes across multiple collections. If any single operation fails or is aborted, none of the changes will persist. - Line 92 (
tx.oncomplete): Fires when all pending requests have successfully written to disk and the transaction is committed. - Lines 102–106 (
accStore.put(...),tx.abort()): Demonstratestx.abort(). Even thoughaccStore.put()was invoked, callingtx.abort()immediately rolls back Alice's balance to its pre-transaction value. - Lines 120–127 (
await new Promise(...)): Demonstrates the classicTransactionInactiveError. When the JavaScript event loop yields tosetTimeout, the microtask queue empties, causing IndexedDB to auto-commit the transaction.
Expected Browser Render Output
[02:50:00] 🛠️ Created BankDB with initial balances (Alice: $1000, Bob: $500)
[02:50:00] ✅ BankDB ready.
[02:50:05] 💸 Starting atomic transfer: $200 from Alice to Bob...
[02:50:05] 🎉 Transaction Committed! Both balances & audit log updated atomically.
[02:50:10] 📊 Current Balances -> Alice: $800 | Bob: $700
[02:50:15] ⚠️ Testing async gap inside transaction...
[02:50:15] Step 1: Reading Alice record...
[02:50:15] Step 2: Simulating external network fetch (async gap)...
[02:50:15] ❌ CAUGHT BUG: TransactionInactiveError - The transaction has finished.
[02:50:15] 💡 Explanation: Transaction auto-committed during setTimeout async gap!🏋️ Hands-On Exercise
🎯 The Challenge: Atomic Inventory Checkout
Instructions:
- Open database
InventoryDBwith storesstock(keyPath: "sku") andreceipts(autoIncrement: true). - Seed
stockwith:{ sku: "LAPTOP-01", name: "MacBook Pro", qty: 2 }. - Implement an
orderItem(sku, requestedQty)function:- Start a
readwritetransaction locking['stock', 'receipts']. - Read the SKU stock.
- If
stock.qty >= requestedQty, subtract the quantity, save the updated stock, write a receipt, and commit. - If
stock.qty < requestedQty, calltx.abort()with a log: "Out of stock! Transaction aborted."
- Start a
- Test ordering 3 laptops (which exceeds the 2 in stock) and verify that stock remains untouched at
2.
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Placing
fetch(),crypto, orsetTimeoutinside transactions: Any asynchronous operation that does not return anIDBRequestcauses the transaction to auto-commit before the asynchronous work completes, resulting inTransactionInactiveError. Fetch all external network data before opening the transaction. - Over-scoping
readwritetransactions: Opening areadwritetransaction across your entire database blocks all other read/write operations from executing concurrently. Only lock the specific ObjectStores you intend to modify. - Relying on Default Durability for Mission-Critical Logs: In high-reliability applications, specify
{ durability: 'strict' }to guarantee that data survives unexpected client hardware power outages.
💡 Pro Tips
- Prefer
readonlyTransactions for Concurrency: Multiplereadonlytransactions on the same ObjectStore execute concurrently without waiting for locks. Never usereadwritewhen merely reading records. - Batch Multiple Mutations in a Single Transaction: Creating a new transaction for each individual
store.put()in a loop of 1,000 items creates massive disk-sync overhead. Always wrap batch operations in a singlereadwritetransaction.
📌 Key Takeaways
- Transactions provide full ACID guarantees (Atomicity, Consistency, Isolation, Durability) for client-side storage.
- The three transaction modes are
readonly(concurrent reads),readwrite(exclusive lock writes), andversionchange(schema upgrades). - Transactions can span multiple ObjectStores simultaneously (
db.transaction(['a', 'b'], 'readwrite')). - An active transaction auto-commits as soon as the JavaScript microtask loop runs out of pending
IDBRequestoperations. - Calling
tx.abort()cancels all pending operations and immediately restores data to its pre-transaction state. - --