Skip to content

IndexedDB - Transactions

Transactions are the heart of IndexedDB. They are the only way to read or write data, and they provide the crucial guarantees of safety and consistency. Think of a transaction as a protective bubble for your database operations. Everything inside the bubble either succeeds together, or if one thing fails, the entire bubble pops, and the database is left as if nothing ever happened. This property is called atomicity.

  • Scope: The set of object stores the transaction can access. You define this when you create the transaction (e.g., db.transaction(['store1', 'store2'])). Requesting only the stores you need is a performance best practice.
  • Mode: Determines what the transaction is allowed to do.
  • readonly: (Default) Can only read data. This is the fastest mode because multiple readonly transactions can run concurrently without blocking each other.
  • readwrite: Can read, create, update, and delete data. These transactions are exclusive; only one readwrite transaction can be active on an object store at a time to prevent conflicts.
  • versionchange: A special, exclusive mode that only runs within an onupgradeneeded event. It’s the only place you can modify the database’s structure (its schema), like creating or deleting object stores and indexes.
  • Lifecycle (Auto-commit): A transaction is active as long as you are making new requests against it. Once your code block finishes and there are no more pending requests, the transaction will automatically attempt to commit its changes. You cannot make new requests against a transaction that has already committed or aborted.

Imagine a scenario where you need to transfer a bot from one project to another. This requires two writes: updating the old project record and the new project record. A transaction ensures this happens atomically.

First, a successful transaction:

async function transferBot(db, botId, fromProjectId, toProjectId) {
// Start a 'readwrite' transaction on the 'projects' store
const tx = db.transaction('projects', 'readwrite');
try {
console.log(`Transferring bot ${botId} from project ${fromProjectId} to ${toProjectId}...`);
// Get both project records
const [fromProject, toProject] = await Promise.all([
tx.store.get(fromProjectId),
tx.store.get(toProjectId)
]);
// Perform the logic
fromProject.bots = fromProject.bots.filter(id => id !== botId);
toProject.bots.push(botId);
// Write both changes back to the database
await Promise.all([
tx.store.put(fromProject),
tx.store.put(toProject)
]);
await tx.done; // Explicitly wait for transaction to complete
console.log('Transfer successful!');
} catch (error) {
console.error('Transfer failed! Rolling back changes.', error);
// The transaction automatically aborts on error, no need for tx.abort()
}
}

Now, let’s see what happens if one part of the transaction fails. We’ll simulate an error by trying to add a record with a key that already exists. This will cause the entire transaction to abort, and the first successful put will be rolled back.

async function failedTransactionDemo(db) {
const tx = db.transaction('employees', 'readwrite');
console.log('\n--- Demonstrating a failed transaction ---');
try {
// This first operation will succeed temporarily
await tx.store.put({ id: 5, name: 'Frank', department: 'Security' });
console.log('Temporarily added Frank.');
// This second operation will FAIL because an employee with ID 1 already exists
await tx.store.add({ id: 1, name: 'Duplicate Alice', department: 'Error' });
await tx.done;
} catch (error) {
console.error(`Transaction aborted as expected: ${error.name}`);
}
// Let's check the database. Frank should NOT be there.
const frank = await db.get('employees', 5);
console.log('Is Frank in the database after aborted transaction?', frank); // Should be undefined
}
  • Be Fast: Don’t hold transactions open for long. A transaction can time out and be aborted by the browser if it remains idle.
  • Don’t await non-IDB operations: Never await a network request (like fetch) or a long setTimeout inside an active transaction. The transaction will likely close before your other operation completes, causing subsequent database requests to fail.
  • Be Specific: Always request readonly mode unless you absolutely need to write. It’s more performant.
  • Handle Errors: Always wrap your transaction logic in a try...catch block to gracefully handle potential errors like a ConstraintError or a failed write.