IndexedDB: The Browser's Persistent Key-Value Store for Complex Data
Client-side storage options span a spectrum. Cookies are small (4KB) and sent to the server. sessionStorage is cleared on tab close. localStorage is synchronous and limited to ~5MB. For applications that need to store large amounts of structured data with querying capabilities — think offline document editors, email clients, or games — these are all inadequate.
IndexedDB is a low-level API for client-side storage of significant amounts of structured data including files and blobs. It is transactional, supports indexes for fast queries, works with binary data, and can store gigabytes (subject to available disk space and browser quotas).
The Architecture
IndexedDB is a document-like store — not a relational database. Its structure:
- Database: An origin-scoped container with a version number
- Object stores: Collections of objects (like SQL tables, but without a fixed schema)
- Indexes: Secondary keys on object stores for efficient lookups
- Transactions: All reads and writes happen inside transactions, which provide atomicity
Every operation is asynchronous and event-driven (or Promise-based with wrappers).
Opening a Database
const request = indexedDB.open('myDB', 1)
request.onupgradeneeded = (event) => {
const db = event.target.result
// Create object store with auto-incrementing key
const store = db.createObjectStore('posts', { keyPath: 'id', autoIncrement: true })
// Create index on 'category' for fast lookups
store.createIndex('by_category', 'category', { unique: false })
}
request.onsuccess = (event) => {
const db = event.target.result
// db is ready to use
}
request.onerror = (event) => console.error(event.target.error)
The onupgradeneeded event fires when the database is first created or when the version number increases. This is the only place where the schema (object stores and indexes) can be modified.
Reading and Writing
All operations require a transaction:
// Write
function addPost(db, post) {
return new Promise((resolve, reject) => {
const tx = db.transaction('posts', 'readwrite')
const store = tx.objectStore('posts')
const req = store.add(post)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
// Read by key
function getPost(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction('posts', 'readonly')
const store = tx.objectStore('posts')
const req = store.get(id)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
// Query by index
function getPostsByCategory(db, category) {
return new Promise((resolve, reject) => {
const tx = db.transaction('posts', 'readonly')
const index = tx.objectStore('posts').index('by_category')
const req = index.getAll(category)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
Using idb for a Better API
The raw IndexedDB API is verbose — all those onsuccess and onerror callbacks. The idb library by Jake Archibald wraps it in Promises without adding any other abstractions:
import { openDB } from 'idb'
const db = await openDB('myDB', 1, {
upgrade(db) {
const store = db.createObjectStore('posts', { keyPath: 'id', autoIncrement: true })
store.createIndex('by_category', 'category')
},
})
// Write
await db.add('posts', { title: 'Hello', category: 'tech' })
// Read
const post = await db.get('posts', 1)
// Query by index
const techPosts = await db.getAllFromIndex('posts', 'by_category', 'tech')
// Update
await db.put('posts', { id: 1, title: 'Updated', category: 'tech' })
// Delete
await db.delete('posts', 1)
This is the recommended approach for any production use — the raw API's event model adds no benefit over Promise wrappers for typical usage.
Transactions and Atomicity
A transaction can cover multiple operations that either all succeed or all fail:
const tx = db.transaction(['posts', 'tags'], 'readwrite')
await tx.objectStore('posts').add({ title: 'New post' })
await tx.objectStore('tags').add({ name: 'javascript', postId: 1 })
await tx.done // wait for transaction to commit
If either operation fails, neither is committed. This makes IndexedDB suitable for applications that need consistent state across multiple stores.
Storage Quotas and Persistence
IndexedDB storage is subject to browser quotas. In most browsers, a site can use up to a certain percentage of available disk space (typically 60% of the origin's quota). The Storage API lets you check:
const estimate = await navigator.storage.estimate()
console.log(`Used: ${estimate.usage} bytes of ${estimate.quota}`)
By default, IndexedDB is "best effort" — the browser can evict data under storage pressure. To request persistent storage:
const persistent = await navigator.storage.persist()
if (persistent) console.log('Storage will not be evicted')
This requires user permission in some browsers or is automatically granted to installed PWAs.
Use Cases
- Offline-first apps: Cache API responses and serve them when offline. Most Service Worker offline strategies use IndexedDB to store response bodies.
- Draft saving: Autosave form state or document drafts that survive tab close
- Large dataset caching: Cache thousands of records locally to avoid re-fetching
- Binary data: Store images, audio, or other blobs for local use
Conclusion
IndexedDB is the right tool when localStorage is too small, too synchronous, or structurally too flat. Its transactional model, support for indexes, and handling of binary data make it a proper database — just one that lives in the browser and is scoped to your origin. The raw API is unnecessarily verbose; use idb or a higher-level library like Dexie.js for any real application work. Combined with Service Workers, IndexedDB is the foundation of true offline-capable web applications.
