cswork / libraryOpen practice workspace

Articles

IndexedDB Interview Questions: Transactions, Version Upgrades, and Offline Data

Prepare for IndexedDB interviews with transaction commit boundaries, blocked upgrades, atomic outbox writes, offline retries, and storage failure tests.

An offline notes app says “Saved,” then loses the pending upload after a restart. Both database writes returned success. The problem is that the note and its synchronization intent were committed in separate transactions, leaving a crash window between them.

IndexedDB interview questions become easier when you name three separate boundaries: a request finishing, a local transaction committing, and a server acknowledging an operation. Schema upgrades add a fourth concern: other tabs may still hold connections to an older database version.

This guide develops those boundaries through an atomic note-and-outbox example, a two-tab upgrade trace, and a recovery test matrix. Official platform facts come from MDN's IndexedDB documentation. Our inference and recommendations concern how to design and explain a reliable offline application.

What does IndexedDB give you beyond localStorage?

IndexedDB stores structured records in object stores and supports indexed access and transactions. Its asynchronous API avoids the synchronous storage interface of localStorage, but asynchronous does not mean every operation is free. Cloning large values, collecting enormous result sets, and processing them on the main thread can still affect responsiveness.

The Using IndexedDB guide explains keys, object stores, indexes, and schema changes. In an interview, translate those features into access patterns: fetch a note by ID, list pending operations by account, and find recently modified notes without loading the entire database.

An index is useful when it supports an actual query. Indexing every property adds maintenance work and storage without demonstrating a requirement. A browser database is also not a server authorization boundary: the application must still validate permissions when synchronizing.

Does request success mean the data is committed?

No. A successful request means that request completed within its transaction. A later request can fail and cause the transaction to abort, rolling back its changes. The transaction's complete event identifies successful transaction completion.

Use distinct UI states such as editing, saving locally, saved locally, and synchronized. “Saved locally” belongs after transaction completion. “Synchronized” requires a separate server acknowledgement and local bookkeeping. Combining those labels hides meaningful failure cases from both the user and the interviewer.

Consider two writes: update note N, then insert operation O. If inserting O violates a uniqueness constraint and the transaction aborts, the updated note must not be announced as locally committed. A request-level success handler is therefore the wrong place to display final success for the whole operation.

Completion also should not be marketed as permanent archival storage. Browser storage policy and durability behavior are separate questions. Critical information needs a recovery strategy appropriate to the product, not an unqualified promise that one callback means data can never disappear.

How do you commit a note and its outbox entry together?

Put both stores in one readwrite transaction. Decide the operation ID before starting, validate the input, enqueue both writes synchronously, and resolve the application promise only after transaction completion.

This illustrative callback example assumes the stores already exist and use id and operationId as their respective key paths:

function saveNoteAndIntent(db, note, operationId) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(["notes", "outbox"], "readwrite");
    tx.oncomplete = () => resolve({ operationId });
    tx.onabort = () => reject(tx.error ?? new Error("Save aborted"));

    try {
      tx.objectStore("notes").put(note);
      tx.objectStore("outbox").add({
        operationId,
        noteId: note.id,
        accountId: note.accountId,
        payload: note,
        state: "pending"
      });
    } catch (error) {
      tx.abort();
      reject(error);
    }
  });
}

The useful invariant is specific: a committed local edit has a corresponding pending synchronization operation. Both records belong to the same transaction scope. A synchronous cloning or validation failure aborts that scope; an unhandled asynchronous request error follows the normal abort behavior.

The example deliberately uses add for the operation record. A duplicate operation ID should not silently overwrite a different intent. A production retry path can look up the existing operation and verify an identical payload, but that policy must be designed explicitly. Randomly assigning a new ID to every retry defeats deduplication.

This is local atomicity, not an atomic transaction spanning the browser and server. The network boundary still needs its own protocol.

Why can await fetch cause TransactionInactiveError?

The native transaction lifecycle follows event-loop activity, not the lexical lifetime of an async function. MDN's IDBTransaction reference describes when a transaction is active and when it can automatically commit.

If you open a transaction, wait for an unrelated network request, and then try to enqueue a write, the transaction may no longer be active. Keeping a JavaScript reference to the transaction does not keep it open.

Fetch or compute external inputs first, then open a short transaction for the dependent database operations. If correctness requires reading current database state before writing, perform that read and subsequent write within the transaction's request flow. Do not replace the atomic read-modify-write with a stale read outside the transaction merely to simplify async syntax.

Promise wrappers can improve readability, but they cannot suspend the browser's transaction rules indefinitely. Explain which wrapper you use and whether a promise represents one request or full transaction completion. Avoid the equally inaccurate claim that every await always breaks IndexedDB; the kind of awaited work and wrapper behavior matter.

Why is a database version upgrade blocked?

A schema change must coordinate with existing connections. Opening a higher database version initiates an upgrade flow, but another tab can keep the old connection open. The blocked event documentation explains this waiting condition.

A strong answer gives both sides responsibilities. Existing connections listen for versionchange, stop accepting new database work, and close appropriately. The upgrading page handles blocked with a useful message rather than an endless spinner. Closing a connection does not mean silently discarding the user's unsaved form input.

StepTab A: old pageTab B: new page
Initial stateHolds database version 3Requests version 4
Upgrade requestedReceives version-change notificationWaits for older connections
CoordinationPreserves unsaved UI state and closes connectionExplains which action is needed if blocked
Upgrade transactionMust not keep issuing old-schema workAdds or changes the required stores and indexes
CompletionReloads or reconnects with compatible codeOpens the upgraded database successfully

Do not “fix” the waiting state by deleting the database. That converts a compatibility problem into data loss. Nor should you promise that refreshing one tab closes all other connections; another tab or worker may still hold one.

How should migrations handle old users and failed upgrades?

Users can skip releases. An upgrade from version 1 to version 5 must apply every required transformation, not only the change introduced in version 5. Branch on the old version and maintain fixtures representing supported historical schemas.

Keep schema work bounded. A large rewrite can delay opening the app, while a lazy data migration needs records that identify their format and readers that understand mixed versions. That is a trade-off between startup cost and ongoing compatibility complexity, not a universal recommendation for one approach.

A failed upgrade should leave a diagnosable error and a recovery path. Test uniqueness violations when adding indexes, malformed historical values, and interrupted migration attempts. Exporting or preserving recoverable user data is usually a more defensible starting point than clearing storage and hoping the server has everything.

Schema version and record revision solve different problems. The first describes database structure; the second can help detect conflicting edits. Incrementing the schema version does not resolve two devices editing the same note.

How do offline writes reach the server safely?

Read pending operations, submit them with stable operation IDs, and make the server's acceptance idempotent under the documented retention policy. After acknowledgement, update local synchronization status in another short transaction.

Now walk the uncomfortable case: the server accepts O, but the browser crashes before marking O acknowledged. On restart, O still appears pending and is sent again. Correctness depends on recognizing the same operation, not on assuming the scheduler executes only once.

Two tabs can also try to drain the outbox. A local claim or lease can reduce duplicate work, but it does not remove ambiguity after a crash or expired lease. Server idempotency remains necessary for effects that must not repeat. Define account scope so one signed-in user's pending work is never submitted under another user's credentials.

Conflict resolution needs a product rule. A base revision can let the server reject a stale edit. Merging text may require a dedicated algorithm or user decision. Arrival-time last-write-wins is a choice with consequences, not a property IndexedDB supplies automatically.

What happens when storage is full or evicted?

Browser-managed storage has quotas and can be subject to eviction. MDN's storage quotas and eviction guidance distinguishes best-effort and persistent storage and explains failure conditions. Treat persistence requests as part of a strategy, not permission to stop handling errors.

Separate replaceable cached content from unsynchronized user intent. Evicting an old image thumbnail is different from deleting the only copy of an offline note. When a write fails, keep the editor's data available, explain that local saving failed, and offer recovery appropriate to the app.

A storage estimate is not a reservation. Space can change before the next write. Handle the actual transaction outcome and test the error path. Also define sign-out behavior: privacy may require removing account data, but the UI must address pending changes before promising that sign-out is harmless.

Which failure tests make an interview answer convincing?

Use explicit expected states instead of “test offline mode.” These are proposed tests for the application, not claimed measurements from a running implementation.

Injected conditionExpected evidence
Outbox insertion fails after note request succeedsTransaction aborts; neither new record state is committed
Network await between transaction creation and writeCode handles the inactive transaction rather than reporting success
Second tab holds an old connectionUpgrade reports blocked; old connection can close safely
Server accepts, then browser exits before acknowledgement bookkeepingSame operation ID retries without a second business effect
Quota failure while editingUnsaved content remains recoverable and UI does not say saved

Add a restart after a successful local commit and verify the note and pending operation together. Then test historical database fixtures and two accounts. These cases expose failures that a happy-path CRUD demo cannot.

Sources and Further Reading