IndexedDBClient StorageOfflineAsyncNoSQL

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.

26 min read15 sections
01

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.

02

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.

LocalStorage Limitationsjavascript
// ❌ 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.

03

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 Architecturetext

┌──────────────────────────────────────────────────┐
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.

04

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 ConceptSQL EquivalentMongoDB Equivalent
DatabaseDatabaseDatabase
Object StoreTableCollection
Record (object)RowDocument
keyPath (primary key)PRIMARY KEY_id
IndexINDEXIndex
TransactionTransactionSession/Transaction
CursorCursor / IteratorCursor

Keys and keyPath

Keys in IndexedDBjavascript
// 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).

05

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.

1

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.

2

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.

3

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.

4

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.

5

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 Lifecycletext
indexedDB.open("MyApp", 1)

  ├─ First time (or version upgrade)?
  │   └─ onupgradeneeded
  │       ├─ createObjectStore("products", { keyPath: "id" })
  │       ├─ createIndex("by-category", "category")
  │       └─ Schema is now defined

  ├─ onsuccessdatabase is open
  │   │
  │   ├─ db.transaction(["products"], "readwrite")
  │   │   └─ store.add({ id: 1, name: "Shoes", price: 99 })
  │   │       ├─ onsuccessrecord added
  │   │       └─ onerrorhandle 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]

  └─ onerrordatabase 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.

06

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

open-database.jsjavascript
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

crud-operations.jsjavascript
// 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

index-queries.jsjavascript
// 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.

07

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.

LocalStorageIndexedDB
Storage limit~5-10MB50MB+ (GBs with permission)
API typeSynchronous (blocks main thread)Asynchronous (non-blocking)
Data typesStrings onlyObjects, arrays, blobs, files, ArrayBuffers
QueryingKey lookup only (getItem)Indexes, key ranges, cursors
TransactionsNoYes (atomic read/write groups)
SchemaNone (flat key-value)Object stores with keyPath, indexes, versioning
ComplexityVery simple (4 methods)Complex (verbose API, transactions, versioning)
Best forSmall settings, preferences, flagsLarge datasets, offline data, structured queries
Binary dataNo (base64 encoding workaround)Yes (native Blob, File, ArrayBuffer support)
Web Worker accessNo (main thread only)Yes (available in Web Workers)
When to Use Whichtext
Use LocalStorage when:
Data is small (< 50KB total)
Simple key-value pairs (theme, language, flags)
You want the simplest possible API
No querying neededjust get/set by key
Cross-tab sync is useful (storage event)

Use IndexedDB when:
Data is large (100KBhundreds 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.

08

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.

Real-World: API Cache with Stale-While-Revalidatejavascript
// 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.

09

Performance Insights

IndexedDB's async nature and indexing capabilities make it significantly more performant than LocalStorage for non-trivial data operations.

✓ Done

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.

✓ Done

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.

✓ Done

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.

→ Could add

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.

→ Could add

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.

OperationLocalStorageIndexedDB
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.

10

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.

The Transaction Timing Trapjavascript
// ❌ 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.

11

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.

LibrarySizeStyleBest For
idb~1.2KBThin Promise wrapper over native APIMinimal overhead, close to native, production apps
Dexie.js~25KBFull-featured ORM-like APIComplex queries, relationships, developer experience
localForage~8KBLocalStorage-like API backed by IndexedDBDrop-in async replacement for LocalStorage

idb — The Recommended Wrapper

idb-example.jsjavascript
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.

12

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.

13

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.

14

Practice Section

1

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.

2

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.

3

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.

4

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.

5

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.

15

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).