IndexedDB Basics
Learn the browser's built-in database — a powerful, asynchronous, NoSQL storage engine that handles megabytes of structured data without blocking the main thread. Understand when IndexedDB is the right choice over LocalStorage and how it powers offline-first apps.
Table of Contents
Overview
IndexedDB is a low-level, asynchronous, NoSQL database built into every modern browser. Unlike LocalStorage (which stores small strings synchronously), IndexedDB can store megabytes of structured data — objects, arrays, blobs, even files — without blocking the main thread.
Think of it as a client-side MongoDB. It uses object stores (like collections), supports indexes for fast queries, and wraps operations in transactions for data integrity. It's the storage engine behind offline-first apps, PWAs, and any frontend that needs to cache large datasets locally.
The raw API is notoriously verbose (callback-based, not Promise-based), which is why most developers use wrapper libraries like idb or Dexie.js. But understanding the underlying concepts is essential for interviews and for making informed storage decisions.
Why this matters
"When would you use IndexedDB over LocalStorage?" is a common interview question. The answer tests whether you understand storage trade-offs: sync vs async, 5MB vs 50MB+, strings vs structured data, simple vs complex. IndexedDB is the answer whenever LocalStorage isn't enough.
The Problem: Limits of LocalStorage
LocalStorage works great for small, simple data — a theme preference, a language setting, a feature flag. But it hits hard limits when your storage needs grow.
Synchronous & Blocking
LocalStorage reads/writes block the main thread. Storing or parsing 500KB of JSON takes 5-15ms — enough to drop frames and cause visible jank during page load.
~5MB Size Limit
LocalStorage caps at ~5-10MB per origin. A chat app with message history, an email client caching threads, or a map app storing tiles quickly exceeds this.
Strings Only
Everything must be serialized to strings. No native support for objects, arrays, binary data, or files. JSON.stringify/parse on every read/write adds overhead and complexity.
No Querying
LocalStorage is a flat key-value store. Finding all items where category='shoes' requires loading ALL data, parsing it, and filtering in JavaScript. No indexes, no efficient lookups.
// ❌ Problem 1: Blocking the main thread // Storing 200KB of data blocks for ~5ms const bigData = JSON.stringify(generateLargeDataset()); // ~5ms localStorage.setItem("cache", bigData); // ~3ms // Total: ~8ms of main thread blocked. At 60fps, that's half a frame. // ❌ Problem 2: Size limit try { localStorage.setItem("huge", "x".repeat(10_000_000)); // 10MB } catch (e) { console.error(e); // QuotaExceededError! } // ❌ Problem 3: No querying // "Find all products where price < 50" const allProducts = JSON.parse(localStorage.getItem("products")); const cheap = allProducts.filter(p => p.price < 50); // Had to load and parse ALL products just to filter. // With 10,000 products, this is slow and wasteful. // ❌ Problem 4: No binary data // Can't store images, audio, or files without base64 encoding // (which increases size by ~33%)
When to graduate from LocalStorage
If you need any of these: more than 5MB, async operations, structured queries, binary data storage, or offline-first capabilities — it's time for IndexedDB.
What Is IndexedDB?
IndexedDB is a transactional, object-oriented database system built into the browser. It stores JavaScript objects directly (no serialization needed), supports indexes for fast lookups, and operates entirely asynchronously.
┌──────────────────────────────────────────────────┐ │ IndexedDB │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ Database: "MyApp" │ │ │ │ (version: 3) │ │ │ │ │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ Object Store: "products" │ │ │ │ │ │ keyPath: "id" │ │ │ │ │ │ │ │ │ │ │ │ { id: 1, name: "Shoes", │ │ │ │ │ │ price: 99, category: "foot" } │ │ │ │ │ │ { id: 2, name: "Shirt", │ │ │ │ │ │ price: 45, category: "top" } │ │ │ │ │ │ │ │ │ │ │ │ Indexes: │ │ │ │ │ │ "by-category" → category │ │ │ │ │ │ "by-price" → price │ │ │ │ │ └──────────────────────────────────┘ │ │ │ │ │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ Object Store: "users" │ │ │ │ │ │ keyPath: "email" │ │ │ │ │ └──────────────────────────────────┘ │ │ │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ Features: │ │ • Async API (non-blocking) │ │ • Stores JS objects natively │ │ • Supports blobs, files, ArrayBuffers │ │ • Indexes for fast queries │ │ • Transactions for data integrity │ │ • 50MB+ storage (browser-dependent) │ │ • Origin-scoped (same-origin policy) │ └──────────────────────────────────────────────────┘
Key Properties
- →Asynchronous — all operations are non-blocking (event/callback-based)
- →NoSQL — stores JavaScript objects, not rows and columns
- →Transactional — operations are grouped in transactions for atomicity
- →Indexed — create indexes on object properties for fast lookups
- →Large capacity — 50MB+ per origin (some browsers allow GBs with permission)
- →Origin-scoped — same-origin policy, like LocalStorage
- →Persistent — data survives tab close, browser restart (like LocalStorage)
- →Supports binary data — Blobs, Files, ArrayBuffers stored natively
Not a replacement for a server database
IndexedDB is a client-side cache and offline store — not a replacement for your backend database. Data can be cleared by the user, evicted by the browser under storage pressure, or lost if the user switches devices. Always treat it as a cache with a server as the source of truth.
Core Concepts
IndexedDB has a specific vocabulary. Understanding these five concepts is essential for using the API and answering interview questions.
Database
The top-level container. Each origin can have multiple databases. A database has a name and a version number. Version changes trigger schema upgrades.
Object Store
Like a table in SQL or a collection in MongoDB. Stores JavaScript objects. Each store has a keyPath (primary key) that uniquely identifies records.
Index
A secondary lookup structure on an object property. Enables fast queries like 'find all products where category = shoes' without scanning every record.
Transaction
All read/write operations happen inside a transaction. Transactions are atomic — either all operations succeed or all are rolled back. Modes: 'readonly' or 'readwrite'.
| IndexedDB Concept | SQL Equivalent | MongoDB Equivalent |
|---|---|---|
| Database | Database | Database |
| Object Store | Table | Collection |
| Record (object) | Row | Document |
| keyPath (primary key) | PRIMARY KEY | _id |
| Index | INDEX | Index |
| Transaction | Transaction | Session/Transaction |
| Cursor | Cursor / Iterator | Cursor |
Keys and keyPath
// Option 1: In-line key (keyPath) // The key is a property of the stored object const store = db.createObjectStore("products", { keyPath: "id" }); store.add({ id: 1, name: "Shoes", price: 99 }); // Key is object.id → 1 // Option 2: Out-of-line key // The key is provided separately, not part of the object const store2 = db.createObjectStore("logs", { autoIncrement: true }); store2.add({ message: "User logged in", timestamp: Date.now() }); // Key is auto-generated: 1, 2, 3, ... // Option 3: Key generator + keyPath const store3 = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true, }); store3.add({ text: "Hello", sender: "Alice" }); // Object gets { id: 1, text: "Hello", sender: "Alice" }
Think of it like MongoDB
If you know MongoDB, IndexedDB will feel familiar. Database → Database. Object Store → Collection. Records are JavaScript objects. keyPath is like _id. Indexes work the same way. The main difference is the verbose, callback-based API (which wrapper libraries fix).
How IndexedDB Works
Every IndexedDB interaction follows the same pattern: open a database, start a transaction, perform operations, and handle results via callbacks or promises.
Open (or create) the database
Call indexedDB.open(name, version). If the database doesn't exist, it's created. If the version is higher than the current one, an 'upgradeneeded' event fires where you define or modify object stores.
Define schema in upgradeneeded
The 'upgradeneeded' event is the ONLY place you can create or delete object stores and indexes. This runs when the database is first created or when the version number increases.
Start a transaction
All data operations happen inside a transaction. Specify which object stores you need and the mode ('readonly' or 'readwrite'). Transactions auto-commit when all requests complete.
Perform operations
Use the object store's methods: add(), put(), get(), delete(), getAll(), or open a cursor for iteration. Each method returns a request object with onsuccess/onerror handlers.
Handle results
Results arrive asynchronously via event handlers (onsuccess, onerror) or Promises if using a wrapper library. The transaction auto-commits when all pending requests are done.
indexedDB.open("MyApp", 1) │ ├─ First time (or version upgrade)? │ └─ onupgradeneeded │ ├─ createObjectStore("products", { keyPath: "id" }) │ ├─ createIndex("by-category", "category") │ └─ Schema is now defined │ ├─ onsuccess → database is open │ │ │ ├─ db.transaction(["products"], "readwrite") │ │ └─ store.add({ id: 1, name: "Shoes", price: 99 }) │ │ ├─ onsuccess → record added │ │ └─ onerror → handle error │ │ │ ├─ db.transaction(["products"], "readonly") │ │ └─ store.get(1) │ │ └─ onsuccess → { id: 1, name: "Shoes", price: 99 } │ │ │ └─ db.transaction(["products"], "readonly") │ └─ store.index("by-category").getAll("footwear") │ └─ onsuccess → [all footwear products] │ └─ onerror → database failed to open
Transactions auto-commit
IndexedDB transactions automatically commit when all pending requests inside them have completed. You don't call "commit" explicitly. If any request fails, the entire transaction is rolled back. This ensures data integrity without manual rollback logic.
Basic API Usage
Here's the raw IndexedDB API. It's verbose but important to understand. We'll show the wrapper library version in Section 11.
Opening a Database & Creating Stores
const request = indexedDB.open("ShopDB", 1); // Runs ONLY on first create or version upgrade request.onupgradeneeded = (event) => { const db = event.target.result; // Create an object store with "id" as the primary key const store = db.createObjectStore("products", { keyPath: "id" }); // Create indexes for fast lookups store.createIndex("by-category", "category", { unique: false }); store.createIndex("by-price", "price", { unique: false }); }; request.onsuccess = (event) => { const db = event.target.result; console.log("Database opened:", db.name, "v" + db.version); // Now you can start transactions }; request.onerror = (event) => { console.error("Failed to open DB:", event.target.error); };
CRUD Operations
// Assuming 'db' is the opened database from above // ─── CREATE (add) ─────────────────────────────── function addProduct(db, product) { const tx = db.transaction("products", "readwrite"); const store = tx.objectStore("products"); const req = store.add(product); // Fails if key already exists req.onsuccess = () => console.log("Added:", product.id); req.onerror = () => console.error("Add failed:", req.error); } addProduct(db, { id: 1, name: "Shoes", price: 99, category: "footwear" }); // ─── READ (get) ───────────────────────────────── function getProduct(db, id) { const tx = db.transaction("products", "readonly"); const store = tx.objectStore("products"); const req = store.get(id); req.onsuccess = () => console.log("Found:", req.result); // req.result → { id: 1, name: "Shoes", price: 99, category: "footwear" } } // ─── UPDATE (put) ─────────────────────────────── function updateProduct(db, product) { const tx = db.transaction("products", "readwrite"); const store = tx.objectStore("products"); store.put(product); // Creates or replaces by key } updateProduct(db, { id: 1, name: "Shoes", price: 79, category: "footwear" }); // ─── DELETE ───────────────────────────────────── function deleteProduct(db, id) { const tx = db.transaction("products", "readwrite"); const store = tx.objectStore("products"); store.delete(id); } // ─── GET ALL ──────────────────────────────────── function getAllProducts(db) { const tx = db.transaction("products", "readonly"); const store = tx.objectStore("products"); const req = store.getAll(); req.onsuccess = () => console.log("All products:", req.result); }
Querying with Indexes
// Find all products in the "footwear" category function getByCategory(db, category) { const tx = db.transaction("products", "readonly"); const store = tx.objectStore("products"); const index = store.index("by-category"); const req = index.getAll(category); req.onsuccess = () => { console.log("Footwear:", req.result); // [{ id: 1, name: "Shoes", ... }, { id: 3, name: "Boots", ... }] }; } // Find products in a price range using a cursor function getByPriceRange(db, min, max) { const tx = db.transaction("products", "readonly"); const index = tx.objectStore("products").index("by-price"); const range = IDBKeyRange.bound(min, max); const results = []; index.openCursor(range).onsuccess = (event) => { const cursor = event.target.result; if (cursor) { results.push(cursor.value); cursor.continue(); // Move to next record } else { console.log("Products $" + min + "-$" + max + ":", results); } }; }
add() vs put()
add() inserts a new record and fails if the key already exists. put() inserts or replaces — it's an upsert. Use add() when you want to prevent duplicates, put() when you want to update existing records.
IndexedDB vs LocalStorage
This is the comparison interviewers expect you to know. The two APIs serve different purposes — LocalStorage for simple key-value pairs, IndexedDB for structured data at scale.
| LocalStorage | IndexedDB | |
|---|---|---|
| Storage limit | ~5-10MB | 50MB+ (GBs with permission) |
| API type | Synchronous (blocks main thread) | Asynchronous (non-blocking) |
| Data types | Strings only | Objects, arrays, blobs, files, ArrayBuffers |
| Querying | Key lookup only (getItem) | Indexes, key ranges, cursors |
| Transactions | No | Yes (atomic read/write groups) |
| Schema | None (flat key-value) | Object stores with keyPath, indexes, versioning |
| Complexity | Very simple (4 methods) | Complex (verbose API, transactions, versioning) |
| Best for | Small settings, preferences, flags | Large datasets, offline data, structured queries |
| Binary data | No (base64 encoding workaround) | Yes (native Blob, File, ArrayBuffer support) |
| Web Worker access | No (main thread only) | Yes (available in Web Workers) |
Use LocalStorage when: ✅ Data is small (< 50KB total) ✅ Simple key-value pairs (theme, language, flags) ✅ You want the simplest possible API ✅ No querying needed — just get/set by key ✅ Cross-tab sync is useful (storage event) Use IndexedDB when: ✅ Data is large (100KB – hundreds of MB) ✅ Structured data with relationships ✅ You need to query by properties (not just key) ✅ Binary data (images, files, audio) ✅ Offline-first / PWA requirements ✅ Performance matters (async, non-blocking) ✅ You need transactions for data integrity
They complement each other
It's common to use both in the same app. LocalStorage for simple preferences (theme, language) and IndexedDB for heavy data (cached API responses, offline content, user-generated files). Pick the right tool for each data type.
Real-World Use Cases
IndexedDB powers some of the most demanding client-side storage scenarios. Here are the patterns you'll see in production and discuss in interviews.
Offline-First Apps
Store app data locally so the app works without internet. Sync changes to the server when connectivity returns. Used by Google Docs, Notion, and Figma.
API Response Caching
Cache API responses in IndexedDB to avoid redundant network requests. Serve cached data instantly, then update in the background (stale-while-revalidate pattern).
Chat Message History
Store thousands of chat messages locally for instant access. Query by conversation, date range, or search text using indexes. Slack and Discord use this pattern.
PWA Data Storage
Progressive Web Apps use IndexedDB + Service Workers for offline functionality. The Service Worker caches assets (Cache API), IndexedDB caches data.
Media & File Storage
Store images, audio, video, and documents as Blobs. A photo editing app can store work-in-progress images locally. A music app can cache songs for offline playback.
Form Data & Drafts
Auto-save complex form data (with file attachments) to IndexedDB. If the user closes the tab or loses connection, their work is preserved and can be restored.
// Pattern: serve from IndexedDB instantly, update in background async function fetchWithCache(url) { const db = await openDB("ApiCache", 1); // 1. Check IndexedDB cache const cached = await db.get("responses", url); if (cached && Date.now() - cached.timestamp < 60_000) { // Fresh cache (< 1 min) — return immediately return cached.data; } if (cached) { // Stale cache — return it immediately, refresh in background refreshInBackground(db, url); return cached.data; } // No cache — fetch from network const data = await fetch(url).then(r => r.json()); await db.put("responses", { url, data, timestamp: Date.now() }); return data; } async function refreshInBackground(db, url) { try { const data = await fetch(url).then(r => r.json()); await db.put("responses", { url, data, timestamp: Date.now() }); } catch (e) { // Network failed — stale cache is still better than nothing } }
The offline-first pattern
Read from IndexedDB first (instant), then fetch from network in the background and update the cache. The user sees data immediately, and it's always eventually fresh. This is the core pattern behind offline-first apps and is a strong answer in system design interviews.
Performance Insights
IndexedDB's async nature and indexing capabilities make it significantly more performant than LocalStorage for non-trivial data operations.
Non-Blocking Async Operations
All IndexedDB operations are asynchronous — they don't block the main thread. A 1MB write happens in the background while your UI stays responsive. LocalStorage would freeze the UI for 10-20ms for the same operation.
Indexed Queries Are Fast
With proper indexes, querying 10,000 records by a property takes <5ms. Without indexes (like LocalStorage), you'd load all records, parse JSON, and filter in JS — 50-100ms+ for the same query.
Native Object Storage
IndexedDB stores JavaScript objects directly using the structured clone algorithm. No JSON.stringify/parse overhead. Storing a 100KB object is faster than serializing it to a string for LocalStorage.
Web Worker Access
IndexedDB is available in Web Workers, letting you perform heavy database operations off the main thread entirely. LocalStorage is main-thread only. Use Workers for bulk imports or complex queries.
Batch Operations in Transactions
Group multiple writes in a single transaction for better performance. IndexedDB batches disk I/O within a transaction. 1,000 individual transactions are much slower than 1,000 writes in a single transaction.
| Operation | LocalStorage | IndexedDB |
|---|---|---|
| Store 100KB object | ~8ms (stringify + setItem, blocking) | ~2ms (async, non-blocking) |
| Read 100KB object | ~6ms (getItem + parse, blocking) | ~1ms (async, non-blocking) |
| Query by property (10K records) | ~80ms (load all + filter, blocking) | ~3ms (index lookup, async) |
| Store 1MB of data | ~50ms (blocking — drops 3 frames) | ~10ms (async — UI stays smooth) |
Batch your writes
Opening a transaction has overhead. If you need to write 100 records, do it in one transaction with 100 put() calls — not 100 separate transactions. The difference can be 10x in performance because the browser batches disk I/O within a single transaction.
Common Challenges
IndexedDB is powerful but has a reputation for being difficult to use. Here are the challenges developers face and how to handle them.
Verbose Callback API
The native API uses event handlers (onsuccess, onerror) instead of Promises. Opening a DB, creating a transaction, and reading a record takes 15+ lines of boilerplate.
Schema Versioning
Object stores and indexes can only be created/modified in the 'upgradeneeded' event. Changing your schema requires incrementing the version number and handling migration logic.
Transaction Timing
Transactions auto-commit when idle. If you do an async operation (like fetch) inside a transaction, the transaction may commit before the async work finishes — causing errors.
Debugging Difficulty
IndexedDB data is harder to inspect than LocalStorage. Chrome DevTools has an IndexedDB viewer (Application tab), but it's less intuitive than the simple key-value view for LocalStorage.
// ❌ WRONG: async operation inside a transaction const tx = db.transaction("products", "readwrite"); const store = tx.objectStore("products"); const response = await fetch("/api/products"); // ← Transaction dies here! const products = await response.json(); // This fails — the transaction already auto-committed // while we were waiting for fetch() store.add(products[0]); // TransactionInactiveError! // ✅ CORRECT: fetch first, then use transaction const response = await fetch("/api/products"); const products = await response.json(); const tx = db.transaction("products", "readwrite"); const store = tx.objectStore("products"); for (const product of products) { store.put(product); // All sync operations — transaction stays alive }
The golden rule of transactions
Never await a network request (or any async operation) inside an IndexedDB transaction. Do all async work first, then open the transaction and perform all database operations synchronously within it. The transaction auto-commits as soon as the event loop is idle.
Libraries & Tools
The raw IndexedDB API is powerful but verbose. Wrapper libraries provide a clean, Promise-based interface that makes IndexedDB as easy to use as LocalStorage.
| Library | Size | Style | Best For |
|---|---|---|---|
| idb | ~1.2KB | Thin Promise wrapper over native API | Minimal overhead, close to native, production apps |
| Dexie.js | ~25KB | Full-featured ORM-like API | Complex queries, relationships, developer experience |
| localForage | ~8KB | LocalStorage-like API backed by IndexedDB | Drop-in async replacement for LocalStorage |
idb — The Recommended Wrapper
import { openDB } from "idb"; // Open database (Promise-based, clean syntax) const db = await openDB("ShopDB", 1, { upgrade(db) { const store = db.createObjectStore("products", { keyPath: "id" }); store.createIndex("by-category", "category"); }, }); // Add a product await db.add("products", { id: 1, name: "Shoes", price: 99, category: "footwear" }); // Get by key const product = await db.get("products", 1); // Get all const all = await db.getAll("products"); // Query by index const footwear = await db.getAllFromIndex("products", "by-category", "footwear"); // Update await db.put("products", { id: 1, name: "Shoes", price: 79, category: "footwear" }); // Delete await db.delete("products", 1); // Compare: 5 lines with idb vs 15+ lines with raw API // Same IndexedDB underneath — just a cleaner interface
Use idb in production
The idb library by Jake Archibald (Google Chrome team) is only ~1.2KB and wraps the native API with Promises. It's the most widely recommended wrapper — used by Google's Workbox, Firebase, and many production apps. Know the raw API for interviews, use idb for real code.
Common Mistakes
Using IndexedDB for simple key-value data
Storing a theme preference or feature flag in IndexedDB adds unnecessary complexity. Opening a database, creating a transaction, and handling async results for a single string is overkill.
✅Use LocalStorage for simple, small key-value data (preferences, flags, settings). Reserve IndexedDB for structured data, large datasets, or when you need async operations and querying.
Doing async work inside a transaction
Awaiting a fetch() or setTimeout inside a transaction causes it to auto-commit before your database operations run. You get TransactionInactiveError and lost writes.
✅Do all async work (network requests, timers) BEFORE opening the transaction. Then perform all database operations synchronously within the transaction. Transactions must complete in a single event loop turn.
Not handling version upgrades properly
Changing your schema without incrementing the version number means the upgradeneeded event never fires. Your new object stores and indexes are never created, causing runtime errors.
✅Always increment the version number when changing the schema. Handle migration logic in upgradeneeded — check which stores exist and create/modify only what's needed for each version step.
One write per transaction
Opening a new transaction for each individual write is extremely slow. Each transaction has overhead for disk I/O and locking. Writing 1,000 records in 1,000 transactions can take 10x longer than one transaction.
✅Batch writes in a single transaction. Open one readwrite transaction, perform all put/add calls, and let it auto-commit. This batches disk I/O and is dramatically faster.
Not cleaning up old data
IndexedDB data persists forever (like LocalStorage). Without cleanup, cached data grows unbounded. The browser may evict data under storage pressure, but you shouldn't rely on this.
✅Implement a cleanup strategy: delete records older than N days, cap the total number of records, or clear the cache when the app version changes. Run cleanup on app startup or periodically.
Interview Questions
Q:What is IndexedDB and when would you use it over LocalStorage?
A: IndexedDB is an asynchronous, transactional, NoSQL database built into the browser. Use it over LocalStorage when you need: more than 5MB of storage (IndexedDB supports 50MB+), structured data with queries (indexes enable fast lookups by property), binary data (Blobs, Files), non-blocking operations (async API doesn't freeze the UI), or offline-first capabilities. Use LocalStorage for simple, small key-value pairs like preferences.
Q:What are object stores and how do they relate to databases?
A: An object store is like a table in SQL or a collection in MongoDB — it holds a set of JavaScript objects. Each object store has a keyPath (primary key) that uniquely identifies records. A database contains one or more object stores. Object stores can only be created or deleted in the 'upgradeneeded' event, which fires when the database version changes.
Q:Why is IndexedDB asynchronous?
A: IndexedDB operations involve disk I/O, which can take milliseconds to complete. If the API were synchronous (like LocalStorage), reading or writing large data would block the main thread and freeze the UI. The async API lets operations happen in the background while the UI stays responsive. This is especially important for large datasets — writing 1MB synchronously would drop multiple frames.
Q:How do transactions work in IndexedDB?
A: All read/write operations must happen inside a transaction. You specify which object stores the transaction needs and the mode ('readonly' or 'readwrite'). Transactions are atomic — either all operations succeed or all are rolled back. They auto-commit when all pending requests complete. Important: you cannot do async work (fetch, setTimeout) inside a transaction — it will auto-commit while waiting.
Q:How would you implement offline-first data storage?
A: Use IndexedDB as the primary data store with a sync layer: (1) Read from IndexedDB first for instant UI (no network wait). (2) Fetch from the server in the background. (3) Update IndexedDB with fresh data. (4) When offline, queue writes in IndexedDB. (5) When connectivity returns, sync queued writes to the server. This pattern (stale-while-revalidate + write queue) is the foundation of offline-first apps.
Q:What are indexes in IndexedDB and why do they matter?
A: An index is a secondary lookup structure on an object property. Without an index, finding all products where category='shoes' requires scanning every record (O(n)). With an index on 'category', the same query is O(log n) — dramatically faster for large datasets. Indexes are created in the 'upgradeneeded' event using store.createIndex(name, keyPath). They're essential for any query that doesn't use the primary key.
Q:Compare all browser storage options: LocalStorage, SessionStorage, IndexedDB, and Cookies.
A: LocalStorage: 5-10MB, sync, strings only, persistent, shared across tabs. Best for preferences. SessionStorage: 5-10MB, sync, strings only, tab-scoped, cleared on tab close. Best for temporary state. IndexedDB: 50MB+, async, structured objects + binary, persistent, indexed queries. Best for large/complex data. Cookies: 4KB, sent with every HTTP request, supports HttpOnly/Secure, has expiration. Best for auth tokens. Choose based on: data size, persistence needs, security requirements, and query complexity.
Q:Why do developers use wrapper libraries like idb instead of the raw IndexedDB API?
A: The raw IndexedDB API is callback-based (onsuccess/onerror events), not Promise-based. Opening a database, creating a transaction, and reading a single record takes 15+ lines of boilerplate. Wrapper libraries like idb (~1.2KB) provide a clean Promise/async-await interface — the same operation takes 1-2 lines. The underlying IndexedDB behavior is identical; the wrapper just provides a modern, ergonomic API.
Practice Section
The Offline Chat App
You're building a chat app that should work offline. Users need to see their message history instantly (even without internet) and send messages that sync when connectivity returns. How would you design the storage layer?
Answer: Use IndexedDB as the primary message store. Store messages in an object store with indexes on 'conversationId' and 'timestamp' for fast queries. On app load, read from IndexedDB first (instant). Fetch new messages from the server in the background and merge into IndexedDB. For offline sends, queue outgoing messages in a separate 'outbox' object store. When connectivity returns, sync the outbox to the server and move messages to the main store.
The Large Dataset Cache
Your dashboard displays 50,000 product records. Loading them from the API takes 3 seconds. Users complain about the slow initial load. How would you use IndexedDB to improve this?
Answer: Cache the product data in IndexedDB after the first API fetch. On subsequent visits, load from IndexedDB first (<100ms for 50K records with indexes) and display immediately. Fetch updates from the API in the background using a 'last-updated' timestamp to get only changed records (delta sync). Use indexes on commonly filtered fields (category, price) for fast client-side filtering without loading all records into memory.
The Photo Editor Draft
Your web-based photo editor needs to auto-save work-in-progress images so users don't lose their edits if they accidentally close the tab. Images can be 5-20MB each. Where would you store them?
Answer: Use IndexedDB — it supports Blob storage natively and has 50MB+ capacity. Store each image as a Blob in an object store with metadata (filename, timestamp, dimensions). Auto-save on a debounced interval (every 30 seconds of inactivity). On app load, check for saved drafts and offer to restore. LocalStorage can't handle binary data or files this large. Clean up old drafts after 7 days to prevent unbounded growth.
The Storage Decision
Your app needs to store: (1) user's theme preference, (2) auth JWT token, (3) 10,000 cached search results, (4) a multi-step form's current step. Which storage mechanism would you use for each and why?
Answer: (1) Theme: LocalStorage — small, persistent, shared across tabs, not sensitive. (2) JWT: HttpOnly cookie — must be protected from XSS, sent automatically with requests. (3) Search results: IndexedDB — large structured dataset, needs indexed queries, async for performance. (4) Form step: SessionStorage — temporary, tab-scoped, should reset in new tabs. Each choice matches the data's lifecycle, size, sensitivity, and access pattern.
The Version Migration
Your app stores user data in IndexedDB. You need to add a new 'tags' field to every record in the 'notes' object store and create an index on it. How do you handle this schema change for existing users?
Answer: Increment the database version number (e.g., 1 → 2). In the 'upgradeneeded' handler, check the old version and apply migrations: create the new index with store.createIndex('by-tags', 'tags', { multiEntry: true }). For existing records without 'tags', open a cursor after the upgrade and add a default empty array. The upgradeneeded event runs automatically when existing users open the app with the new code.
Cheat Sheet
Quick Revision Cheat Sheet
IndexedDB: Async, transactional, NoSQL database in the browser. Stores JS objects, blobs, files. 50MB+ capacity. Origin-scoped, persistent. The browser's built-in database.
Object Store: Like a table (SQL) or collection (MongoDB). Holds JS objects with a keyPath (primary key). Created only in the 'upgradeneeded' event.
Index: Secondary lookup on an object property. Enables fast queries by non-key fields. store.createIndex('by-category', 'category'). Without indexes, queries scan all records.
Transaction: All operations happen inside transactions. Modes: 'readonly' or 'readwrite'. Atomic — all succeed or all roll back. Auto-commits when idle. Never await async work inside one.
add() vs put(): add() inserts and fails if key exists. put() inserts or replaces (upsert). Use add() to prevent duplicates, put() to update existing records.
Version Upgrades: Schema changes (new stores, indexes) only happen in 'upgradeneeded'. Increment the version number to trigger it. Handle migrations by checking oldVersion.
vs LocalStorage: LocalStorage: 5MB, sync, strings, simple. IndexedDB: 50MB+, async, objects/blobs, indexed queries, transactions. Use LocalStorage for small prefs, IndexedDB for everything else.
Offline-First Pattern: Read from IndexedDB first (instant). Fetch from server in background. Update IndexedDB. Queue offline writes. Sync when connectivity returns.
idb Library: ~1.2KB Promise wrapper by Jake Archibald. Turns 15 lines of callbacks into 1-2 lines of async/await. Same IndexedDB underneath. Use for production code.
Transaction Trap: Never do async work (fetch, setTimeout) inside a transaction. It auto-commits when idle. Do async work first, then open transaction and perform all DB operations.
Performance: Batch writes in one transaction (10x faster). Use indexes for queries. Available in Web Workers for off-main-thread operations. Native object storage — no JSON overhead.
When NOT to Use: Simple key-value pairs (use LocalStorage). Auth tokens (use HttpOnly cookies). Temporary tab state (use SessionStorage). Server-side data source of truth (IndexedDB is a cache).