IndexedDB Explained For Offline Web Apps
Local storage breaks down when a web app needs searchable data, offline writes, and more than a few strings. IndexedDB is the browser database for that job, if the app treats it like a cache with rules instead of a tiny server.
A web app can load fast and still feel broken the moment the network drops. The product list disappears. A draft form loses its unsent changes. A dashboard re-fetches the same data on every tab switch because the browser only kept a few strings in localStorage.
IndexedDB is the browser storage API for structured data that needs keys, indexes, transactions, and offline access. It is not a replacement for the server database. It is the local database that keeps a web app useful when the network is slow, absent, or too expensive to hit for every read.
The Short Version
IndexedDB stores JavaScript objects in named collections called object stores. An object store is similar to a table because it groups one kind of record, but IndexedDB is not SQL and it does not query with SQL. A transaction is a group of reads and writes that either completes together or fails together.
The most popular use case is offline-first app state: keep enough data on the device for the app to open, search, edit, and queue changes while the network is unreliable. Email clients, note apps, project boards, point-of-sale screens, and field-service apps all hit this shape.
Why localStorage Stops Being Enough
localStorage stores strings behind a synchronous API. Synchronous means the browser has to wait while the operation runs. That is fine for a small theme setting or one token-shaped value. It is a poor fit for thousands of records, binary blobs, search indexes, and writes that should not block the page.
IndexedDB is asynchronous, so reads and writes happen through requests and callbacks or through a promise wrapper library. It can store structured clone values, which means ordinary objects, arrays, dates, blobs, and files can be persisted without manually converting everything to strings.
The trade-off is complexity. IndexedDB asks the app to think about schema versions, transactions, indexes, and quota failures. That cost is worth paying when the app needs local data with shape.
The Mental Model
Think of IndexedDB as a small document database per browser origin. An origin is the combination of scheme, host, and port, such as https://example.com. Data from one origin cannot be read by another origin.
Three nouns matter most:
- Database, the named container opened by the app.
- Object store, the collection of records inside the database.
- Index, a lookup path for a field that the app searches by often.
Without an index, the app may need to walk many records to find one matching field. With an index, the browser maintains a lookup structure for that field.
How IndexedDB Works
Opening a database includes a version number. When the version increases, the browser runs an upgrade step. That is where the app creates object stores and indexes.
After the database opens, every operation runs inside a transaction. A readonly transaction can read data. A readwrite transaction can change data. The browser commits the transaction when all queued requests finish successfully.
The API feels old because it is event based. Many production apps use a small wrapper such as idb to get promises, but the underlying rules stay the same.
A Small Runnable Shape
This example uses the native API so the moving parts are visible. It creates a products object store with an index on sku, then saves and reads one product.
const openRequest = indexedDB.open('catalog-db', 1);
openRequest.onupgradeneeded = () => {
const db = openRequest.result;
// The key path gives every product a stable primary key.
const products = db.createObjectStore('products', { keyPath: 'id' });
// The index makes lookup by SKU cheap without scanning all products.
products.createIndex('by_sku', 'sku', { unique: true });
};
openRequest.onsuccess = () => {
const db = openRequest.result;
const writeTx = db.transaction('products', 'readwrite');
const products = writeTx.objectStore('products');
products.put({
id: 'product-42',
sku: 'MUG-BLACK-12OZ',
name: 'Black 12 oz mug',
priceCents: 1800,
updatedAt: '2026-07-04T00:00:00.000Z',
});
writeTx.oncomplete = () => {
const readTx = db.transaction('products', 'readonly');
const bySku = readTx.objectStore('products').index('by_sku');
const readRequest = bySku.get('MUG-BLACK-12OZ');
readRequest.onsuccess = () => {
console.log(readRequest.result);
};
};
};Expected output:
{
id: 'product-42',
sku: 'MUG-BLACK-12OZ',
name: 'Black 12 oz mug',
priceCents: 1800,
updatedAt: '2026-07-04T00:00:00.000Z'
}The load-bearing detail is the index:
products.createIndex('by_sku', 'sku', { unique: true });That line tells the browser to maintain a second lookup path. The app can now ask for a product by sku without knowing the primary key and without scanning the whole store.
The Popular Use Case: Offline App State
The common pattern is not "put everything in the browser forever." The common pattern is "keep a useful local working set and sync it with the server."
A read model is the local copy shaped for screens, such as products by category or tasks by project. An outbox is a local queue of changes waiting to be sent. This split keeps reads fast and makes writes survivable when the network is absent.
For example, a field-service app can cache today's jobs and queue completed inspection forms. The technician can keep working in a basement with no signal. When the connection returns, the app replays the outbox and refreshes the local read model from the server.
That design has a cost. The app must decide what happens when the server rejects a queued write, when another device edited the same record, or when local data is too old to trust. IndexedDB stores the data, but the product still needs sync rules.
Best Practices
Keep The Schema Small And Versioned
Create object stores around access patterns, not around every server table. A browser database should serve the current app, not mirror the entire backend.
Version upgrades should be boring and reversible in spirit, even though IndexedDB migrations themselves run forward. Keep migrations small, test upgrades from older versions, and avoid deleting old stores until the new code has shipped safely.
Index What The UI Actually Searches
Indexes are not free. Each index makes writes do more work because the browser must keep that lookup updated. Add indexes for fields used in common reads, such as sku, projectId, updatedAt, or status. Do not add indexes just because the server has them.
Treat Local Data As Recoverable
Browsers can evict site storage under pressure, and users can clear site data. For important records, keep the server as the source of truth or ask for persistent storage at a moment that makes sense to the user, such as after they save critical offline work.
Use Transactions As Boundaries
Put related writes in the same readwrite transaction. If saving an order also appends an outbox entry, those two writes should complete together. Splitting them creates a failure mode where the UI sees saved local data but the sync worker has nothing to send.
Store Sync Metadata Beside The Data
Keep fields such as updatedAt, serverVersion, dirty, or lastSyncedAt when the sync algorithm needs them. Metadata makes conflict detection explicit. Without it, the app tends to guess whether local data is fresh.
Keep Large Blobs Intentional
IndexedDB can store blobs and files, but large media changes quota behavior and backup expectations. Store thumbnails or compressed assets when the UI only needs previews. For durable originals, sync them to server storage as soon as possible.
Wrap The Native API, But Learn The Native Rules
A promise wrapper can make IndexedDB easier to read. It does not remove schema versions, transaction lifetimes, blocked upgrades, quota errors, or origin scoping. Teams should document the rules once and hide the repetitive plumbing behind a small storage module.
When To Use IndexedDB
Use IndexedDB when a web app needs one or more of these:
- Offline reads for meaningful structured data.
- Offline writes that can be queued and synced later.
- Searchable local records through indexes.
- More data than belongs in
localStorage. - Blobs or files that should stay near the UI.
- A local cache that survives page reloads.
It is especially strong for progressive web apps, offline dashboards, local-first editors, cached catalogs, draft forms, and client-side search over a bounded data set.
When Not To Use IndexedDB
Do not use IndexedDB for secrets that should not live on the user's device. Browser storage is reachable by the app's JavaScript, so cross-site scripting bugs can become data theft bugs. Cross-site scripting means an attacker manages to run their JavaScript in the page.
Do not use it as the only copy of business-critical data. A user can clear storage, switch devices, use private browsing, or lose a profile. If losing the local database would be unacceptable, sync the data somewhere durable.
Do not use it for tiny preferences where localStorage or cookies are simpler. A theme, a dismissed banner, or a last-opened tab does not need a database.
Do not use it to avoid designing an API. The browser database helps the client stay responsive. It does not solve authorization, multi-device consistency, backups, reporting, or server-side workflows.
Gotchas That Bite Later
Blocked upgrades happen when an old tab still has the database open while a new tab wants to migrate it. The new code may wait until the old connection closes. A production app needs a user-visible "please refresh" path or a safe way to close old connections.
Transaction lifetime is shorter than many developers expect. If code starts a transaction and then waits on unrelated asynchronous work, the transaction may become inactive. Keep IndexedDB work inside the transaction's own request chain or use a wrapper that makes this rule hard to violate.
Quota errors are real. Storage limits vary by browser, device, free disk space, user settings, and persistence status. Estimate storage usage, handle write failures, and give the user a recovery path.
Conflict resolution is product logic. IndexedDB can queue two edits. It cannot decide whether the second edit should win, merge, or ask the user. That rule belongs near the sync layer.
Private browsing and embedded browsers can behave differently. Test the app in the environments where users actually run it, including mobile browsers and in-app webviews if those are supported.
Alternatives
localStorage is simpler for small string values, but it is synchronous and not built for structured queries.
The Cache API is better for HTTP responses, static assets, and service worker caching. It stores request and response pairs, not arbitrary application records with indexes.
Cookies are for small values that must travel with HTTP requests. They are a bad place for app state because every request can carry them.
A server database is still the source of truth for shared business data. IndexedDB complements it by keeping a local working set near the UI.
SQLite in WebAssembly can be a good fit when the app needs SQL semantics in the browser, but it adds another runtime layer and operational choices. IndexedDB is built into the browser.
References
- MDN Web Docs, "IndexedDB API." MDN Web Docs.
- MDN Web Docs, "Using IndexedDB." MDN Web Docs.
- W3C, "Indexed Database API 3.0." W3C.
- Chrome for Developers, "Storage for the web." web.dev.
- Chrome for Developers, "Persistent storage." web.dev.
Takeaway
IndexedDB is worth the complexity when the browser needs a real local working set, but it stays safe only when the app treats local data as synced, versioned, and recoverable.