IndexedDB

Use transactions and unblock upgrades

Group related writes atomically and close stale connections when another tab needs a newer database version.

Every IndexedDB write already runs inside a transaction. db.put() opens a short one for you. That’s fine for a single write. When two writes belong together, you need one explicit transaction that holds both.

One operation, one transaction

Say we add a changes store that logs what happened to each note, so we can sync later. Saving a note means two writes: the note, and its change entry. If the note saves and the log entry fails, the sync never learns about the edit.

A readwrite transaction across both stores makes them one unit:

const tx = db.transaction(['notes', 'changes'], 'readwrite')
await tx.objectStore('notes').put(note)
await tx.objectStore('changes').add({ noteId: note.id, type: 'saved' })
await tx.done

tx.done is a promise that resolves when the transaction commits and rejects if it aborts. If the add() fails, the whole transaction aborts and the note write is rolled back too. Open DevTools afterwards and the notes store is unchanged. All or nothing, which is the point.

The changes store needs to exist first, so create it in a version 3 upgrade, the same way we added the index in version 2.

Don’t wait on the network mid-transaction

An IndexedDB transaction stays alive only while it has pending requests. The moment the browser finishes your last request and finds nothing else queued, it commits.

So this is a bug:

const tx = db.transaction('notes', 'readwrite')
const note = await tx.objectStore('notes').get(id)
await fetch('/api/sync', { method: 'POST', body: JSON.stringify(note) })
await tx.objectStore('notes').put({ ...note, synced: true }) //TransactionInactiveError

While fetch() runs, the transaction has no pending requests, and the browser closes it. The put() then throws TransactionInactiveError. Do the network call before or after the transaction, never inside it. Same for setTimeout, reading a file, or anything else that yields.

Upgrades blocked by another tab

This happens all the time. A user has the app open in tab A, at version 2. They open tab B after you deployed version 3. Tab B calls openDB('field-notes', 3). The browser can’t upgrade while tab A still holds a version 2 connection, so tab B waits. Forever, if nobody acts.

The idb wrapper gives both sides a hook. In tab A, the old connection gets a blocking() callback. Use it to close the connection and tell the user why:

export const db = await openDB('field-notes', 3, {
  upgrade(db, oldVersion) {
    // version 1, 2, 3 blocks as before
  },
  blocking() {
    db.close()
    showBanner('A newer version is open in another tab. Refresh to continue.')
  },
})

Once tab A closes its connection, tab B’s upgrade proceeds. Tab A now has no database, so the banner matters. Closing silently would leave the user typing into a note that never saves. There’s also a blocked() callback for the new connection’s side, useful for showing “waiting for other tabs” in tab B.

Create the changes store in version 3. Make its write fail, for example by giving changes a key path and passing a record without it, and prove the note didn’t commit either. Then open an old tab at version 2, load the version 3 code in a new tab, and check the old tab shows the banner instead of hanging.

Lesson completed