What this usually means
IndexedDB storage failures are rarely random. The most common pattern is a transaction that was never committed because the developer forgot to call transaction.commit() in a readwrite transaction, or the transaction was auto-committed before the put() completed due to a microtask ordering issue. Another frequent cause is the browser throwing a QuotaExceededError that was caught and swallowed by a generic try/catch. Third is a version mismatch: if onupgradeneeded doesn't run because the database already exists at a higher version, your object store creation is skipped, and writes go to a nonexistent store silently. Fourth is that the database was blocked by an open connection in another tab, causing open() to fail without firing an error event. Finally, private browsing modes in some browsers limit IndexedDB storage to in-memory only, causing data loss on tab close.
The first ten minutes — establish facts before touching code.
- 1Run indexedDB.databases() in the console to list all databases and their versions. Compare to expected version.
- 2Open the Application > IndexedDB panel in DevTools, select your database, and check if the object store exists and has entries.
- 3Add a transaction.oncomplete callback and a transaction.onerror callback to every write transaction. Put a console.log or debugger breakpoint inside.
- 4Check the browser's storage quota: in Chrome, navigate to chrome://quota-internals/ and look at 'Temporary Storage' usage. If near 80%, that's your culprit.
- 5Test the same write operation in an incognito window. If it works, the issue is likely an open connection or quota in the normal profile.
- 6Wrap your open() call in a try/catch and log the error object. An InvalidStateError or AbortError indicates a blocked database.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchApplication > IndexedDB panel in Chrome DevTools — verify object store existence and row count
- searchchrome://quota-internals/ — temporary storage usage and pool size
- searchBrowser console — hidden errors from transaction.onerror that aren't propagated
- searchYour code's transaction lifecycle — look for missing .commit() or premature async/await patterns
- searchService Worker file (if using one) — IndexedDB in SW has different lifetime rules
- searchBrowser's local profile folder (e.g., ~/Library/Application Support/Google/Chrome/Default/IndexedDB/) — check if .leveldb files are present
- searchNetwork tab — if you're using a polyfill like fakeIndexedDB, it may not persist to disk
Practical causes, not theory. These are the things you will actually find.
- warningTransaction not committed: missing transaction.commit() in readwrite transactions, or auto-commit happens before the put() resolves
- warningQuotaExceededError silently caught: a try/catch around the write that logs nothing or re-throws incorrectly
- warningonupgradeneeded not firing: database already exists at a higher version, so object stores are not created on open
- warningDatabase blocked by an open connection: another tab or window has the database open with a lower version, causing open() to wait forever or fail
- warningPrivate browsing mode: in Safari or Firefox, IndexedDB may be memory-only and cleared on tab close
- warningLarge write fails: writes exceeding the remaining quota (typically 80% of disk free) throw QuotaExceededError
- warningUsing setVersion or outdated API: calling database.setVersion() in old Chrome versions blocks subsequent open calls
Concrete fix directions. Pick the one that matches your root cause.
- buildExplicitly call transaction.commit() after all put() operations in a readwrite transaction, or ensure the transaction completes before the event loop continues
- buildAlways handle transaction.onerror: log the error event and its target.error property to see the actual DOMException
- buildIn onupgradeneeded, always check if the object store already exists before creating: if (!db.objectStoreNames.contains('storeName')) db.createObjectStore(...)
- buildBefore writing large data, check navigator.storage.estimate() and compare usage against quota. If near limit, prompt user or clear old data.
- buildClose all other tabs/windows that may have the database open. Use version bumping carefully: only increment version when schema changes.
- buildFor service workers, ensure the transaction completes before the SW terminates: call event.waitUntil(transactionPromise).
- buildWrap the entire open() + transaction flow in a single try/catch and differentiate between AbortError, InvalidStateError, and QuotaExceededError.
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter writing data, immediately open a new tab, navigate to the same page, and check the object store count — it should match the written count
- verifiedPut a breakpoint in transaction.oncomplete and verify it fires for every write
- verifiedLog the result of navigator.storage.estimate() before and after the write to confirm quota usage increases
- verifiedUse the DevTools console to call indexedDB.open() with the same version and list stores — they must match your schema
- verifiedWrite a small payload (1KB) and a large payload (10MB) separately to see if size is the factor
- verifiedClose the browser completely, reopen, and verify data persistence (private browsing will fail this test intentionally)
Things that make this bug worse or harder to find.
- warningDo not assume that a successful put() call means the data is committed — check transaction state
- warningDo not wrap IndexedDB operations in a generic try/catch that swallows all errors — catch specific DOMException types
- warningDo not create object stores outside of onupgradeneeded — they will be silently ignored
- warningDo not reuse a transaction after it has been committed or aborted — create a new one for each batch
- warningDo not rely on setTimeout to wait for writes — use the oncomplete event
- warningDo not ignore the browser console's 'IndexedDB' filter — errors are often hidden under verbose levels
The Vanishing Shopping Cart: IndexedDB Write That Never Stuck
Timeline
- 09:15User reports cart items disappear after page refresh on production e-commerce site
- 09:22I reproduce locally: add item to cart, see it in UI, refresh — cart empty
- 09:28Check DevTools > Application > IndexedDB: database exists, object store 'cart' exists, but count = 0
- 09:35Add console.log inside transaction.oncomplete — it never fires. onerror fires but error is null
- 09:42Check chrome://quota-internals/: Temporary storage is at 95% of 1GB quota
- 09:50Write a small test item (1KB) — it persists. Write a realistic cart (2.5MB) — fails silently
- 10:05Add QuotaExceededError handling: catch and display user-friendly message
- 10:15Deploy fix. Data persists. Root cause: QuotaExceededError was being caught by a generic try/catch that logged 'IndexedDB error' without the actual error type.
I was paged about a production issue where users' shopping carts were randomly emptying on page refresh. The cart was stored in IndexedDB to support offline browsing. I opened the site, added a few items, saw them appear in the UI, then hit refresh. The cart was gone. Console was clean — no errors. My first thought was a transaction issue.
I opened Chrome DevTools and navigated to Application > IndexedDB. The database 'shop' and object store 'cart' existed, but the store was empty. I added a debug line inside transaction.oncomplete — it never fired. onerror fired but the event's error property was null. That's weird. I suspected the transaction was aborting silently.
I checked chrome://quota-internals/ and saw that temporary storage was at 950MB out of 1GB. The cart payload was about 2.5MB. I wrote a tiny 1KB record — it persisted. Ah! QuotaExceededError was being swallowed. I traced the code: a generic try/catch around the write was catching the error and logging 'IndexedDB operation failed' without the error name. I added specific handling for QuotaExceededError, and the fix was deployed. The lesson: always handle transaction errors explicitly and check quota before large writes.
Root cause
QuotaExceededError silently caught by a generic try/catch that discarded the error type, causing the transaction to abort without visible feedback.
The fix
Added explicit QuotaExceededError handling: catch error, check if error.name === 'QuotaExceededError', then prompt user to clear space. Also added a pre-write quota check using navigator.storage.estimate().
The lesson
Never trust that a try/catch around IndexedDB is enough — you must inspect the error name. Always use transaction.onerror to catch failures. For large data, estimate quota before writing.
IndexedDB transactions have a strict lifecycle: they are created, you queue requests (like put()), and then they auto-commit once all requests complete and the event loop returns. However, if you call put() inside a microtask (e.g., a resolved Promise.then()), the transaction may auto-commit before the put() is queued. This results in a 'transaction already finished' error that some libraries swallow.
To prevent this, always create a new transaction for each batch of writes, and don't mix async/await patterns that spread requests across microtasks. Use explicit transaction.commit() after the last put() to ensure the transaction doesn't close prematurely. In the idb library, the promise returned by put() waits for the transaction to complete, but if you don't await it, the transaction may auto-commit before the promise resolves.
Another common break: reusing a transaction after an error. If a request fails, the transaction is aborted. Any further puts on that transaction will throw InvalidStateError. Always create a new transaction after an error.
IndexedDB versioning is simple: you pass a version number to open(). If the existing database version is lower, onupgradeneeded fires. If it's equal or higher, onupgradeneeded does not fire. The trap: if you deploy a new version of your app that expects a new object store, but you forget to increment the version number, onupgradeneeded doesn't run and the store is never created. Writes to a non-existent store fail silently (the request errors, but if you don't listen, you won't see it).
Always verify in DevTools that your object store exists. A common fix is to use a monotonically increasing version number (like Date.now()) or a build timestamp as the version. Also, never open the database with a version lower than the current one — it will throw a VersionError.
IndexedDB connections are exclusive per origin and version. If one tab opens a database at version 1, and another tab tries to open it at version 2, the second open() will be blocked until the first tab closes its connection. If the first tab never closes (e.g., a background tab), the second open() hangs indefinitely and never fires onsuccess or onerror.
To debug, check the number of open connections using indexedDB.databases() or look at the chrome://indexeddb-internals/ page. The fix is to always close connections when not in use (db.close()) and to handle the blocked event in your open() call: request.onblocked = () => { /* notify user */ }.
In Safari's private browsing mode, IndexedDB is entirely in-memory and is cleared when the tab closes. Firefox private mode also clears IndexedDB on close. Chrome's incognito mode persists IndexedDB to disk but clears it when all incognito windows close. If your app relies on IndexedDB for persistence, you must detect private browsing (via storage.estimate() returning 0 or a QuotaExceededError on small writes) and fall back to in-memory storage or prompt the user.
Additionally, browsers may evict IndexedDB data under storage pressure (e.g., when disk space is low). Chrome evicts data from least recently used origins first. To prevent unexpected data loss, listen for the 'storage' event on window (but note: IndexedDB eviction does not fire this event). The only reliable way is to check data integrity on startup.
Frequently asked questions
Why does IndexedDB.put() return undefined instead of a promise?
IndexedDB uses a request-based API, not promises. The put() method returns an IDBRequest object. To get a promise-compatible API, you need to wrap the request in a Promise that resolves on success and rejects on error. Libraries like idb do this for you. If you call put() without awaiting the request's result, the transaction may auto-commit before the data is written, causing silent failure.
Can IndexedDB be used in Service Workers? How does persistence differ?
Yes, IndexedDB works in Service Workers, but the transaction lifecycle is tied to the event handler. If the Service Worker terminates before the transaction completes, the write is lost. Use event.waitUntil(transactionPromise) to extend the SW's lifetime until the transaction finishes. Also, in SW, you cannot use synchronous APIs, so always use async/await or promises.
How do I check if IndexedDB is available in the current browser?
Simply check: if (typeof indexedDB !== 'undefined' && indexedDB !== null) { /* available */ }. However, even if available, it may be disabled in private browsing or due to storage policy. A more robust check is to attempt an open() and catch errors. Note that in some older browsers, the variable exists but the API throws when used.
What's the maximum size for IndexedDB?
There is no fixed maximum. Browsers typically allow up to 80% of free disk space, but this is shared among all origins. In Chrome, you can see the limit and usage at chrome://quota-internals/. For large datasets, always check remaining quota before writing and handle QuotaExceededError gracefully.
Why does my IndexedDB data disappear after clearing browser cache?
Clearing 'cached images and files' usually does not affect IndexedDB. However, if you select 'Cookies and other site data' or 'All time' in Chrome's clear browsing data, IndexedDB for all origins is deleted. In Firefox, IndexedDB is grouped under 'Offline website data'. Always verify what the clear option includes. To prevent accidental deletion, store a backup key in localStorage that indicates whether IndexedDB should have data.