JavaScript IndexedDB: Using the Browser's Offline Database Correctly
AI generated
JS
() =>
JavaScript · IndexedDB · Offline-first · PWA · Browser Storage
JavaScript IndexedDB
Using the Browser's Offline Database Correctly

IndexedDB is the most powerful offline storage solution in the browser: structured data, gigabyte-scale capacity, transactions, indexes and asynchronous queries without ever blocking the UI thread. Once you master the fundamentals, you can build offline-first apps that work fully even without a network connection.

18 min read Transactions · Indexes · Cursors · Sync · Service Worker Browser · PWA · Offline-first · Dexie.js

1. Why IndexedDB instead of localStorage and sessionStorage?

IndexedDB and localStorage solve fundamentally different problems. localStorage is synchronous and blocks the main thread on every read and write. It only accepts strings, has a hard limit of 5 to 10 MB depending on the browser, and does not support structured queries. For simple preferences and small amounts of data that is enough, but for offline-first apps, caches, user data and larger datasets it is the wrong tool.

IndexedDB is fully asynchronous: every operation runs in background threads and never blocks the UI thread. It accepts JavaScript objects directly, without JSON serialization, and supports arrays, blobs, dates and nested structures. The storage capacity is enormous: in modern browsers typically 50% of the available device storage or more. With indexes and cursors you can perform targeted queries, sorting and range queries, functionality that localStorage entirely lacks. For progressive web apps, document editors, email clients and any application that needs to work offline, IndexedDB is the only serious option.

2. Database structure: object stores, keys and versioning

IndexedDB organizes data in object stores, comparable to tables in relational databases, but without a fixed schema. Each object store has a key path or an auto-increment key. The key path defines which property of the stored object is used as the key, for example id or email. Auto-increment automatically generates ascending numeric keys, similar to SERIAL in PostgreSQL.

The database schema is versioned. When a new version of the app needs a new schema (a new object store, a new index, removing an outdated store), the version number is increased. The browser then invokes the onupgradeneeded handler before any other transactions can begin. That is the only moment the schema can be changed. Migrations in IndexedDB are therefore declarative: every version number corresponds to a specific schema state, and the upgrade handler brings the store up to date incrementally. This matters for apps that may have multiple schema versions active simultaneously across different devices.


// Open IndexedDB with schema migration support
const DB_NAME = 'mironsoft-app';
const DB_VERSION = 3;

/**
 * Opens the database and handles schema migrations.
 * @returns {Promise<IDBDatabase>}
 */
function openDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      const oldVersion = event.oldVersion;

      // Incremental migrations, each version builds on the previous one
      if (oldVersion < 1) {
        // Version 1: basic user store with email index
        const users = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
        users.createIndex('by_email', 'email', { unique: true });
      }

      if (oldVersion < 2) {
        // Version 2: add orders store with compound index
        const orders = db.createObjectStore('orders', { keyPath: 'id' });
        orders.createIndex('by_user_status', ['userId', 'status'], { unique: false });
        orders.createIndex('by_created_at', 'createdAt', { unique: false });
      }

      if (oldVersion < 3) {
        // Version 3: add sync queue for offline operations
        const syncQueue = db.createObjectStore('sync_queue', {
          keyPath: 'queueId',
          autoIncrement: true,
        });
        syncQueue.createIndex('by_entity', 'entity', { unique: false });
      }
    };

    request.onblocked = () => {
      // Another tab has an older version open, ask the user to reload
      console.warn('IndexedDB upgrade blocked, please close other tabs');
    };
  });
}

4. Transactions: ACID properties in the browser

IndexedDB transactions are ACID compliant: atomic, consistent, isolated and durable. A transaction spans one or more object stores and has a defined access mode: readonly for read access and readwrite for read and write access. Readonly transactions can run in parallel; readwrite transactions on the same store are serialized. Transactions commit automatically once the last request has completed and no new requests are started, or they roll back automatically on error.

A common mistake: keeping a transaction open across an asynchronous call. If you attempt a write after an await fetch() on a transaction that has already committed, you get a TransactionInactiveError. The reason: IndexedDB transactions have an event loop slot, once all synchronous callbacks in a slot have been processed and control is handed back to the event loop, the transaction commits. Network requests hand control back to the event loop. The solution: fully prepare all the data you want to write before starting the transaction.

5. Indexes: fast queries without a full scan

Without indexes, IndexedDB can only retrieve objects by primary key. For every other query, by email, status, date, category, you would have to load all records and filter them: a full scan. Indexes in IndexedDB significantly speed up queries on arbitrary properties or property combinations. An index on email enables O(log n) lookups by email. A compound index on [userId, status] enables efficient queries such as "all orders for user 42 with status 'pending'".

Queries using indexes rely on the IDBKeyRange object, which enables range queries: IDBKeyRange.only(value) for exact matches, IDBKeyRange.lowerBound(value) for all entries greater than a value, IDBKeyRange.bound(lower, upper) for range queries. Combined with an index, these ranges act like SQL-style WHERE clauses without an SQL engine. Important: indexes are always created in onupgradeneeded, never at runtime. Creating an index on a populated store can take time for large datasets, browsers often show an "Updating" dialog during this phase.


// CRUD operations with proper transaction handling
class OrderRepository {
  #db;

  constructor(db) {
    this.#db = db;
  }

  /**
   * Save or update an order using a readwrite transaction.
   * @param {object} order
   * @returns {Promise<IDBValidKey>}
   */
  save(order) {
    return new Promise((resolve, reject) => {
      const tx = this.#db.transaction('orders', 'readwrite');
      const store = tx.objectStore('orders');

      // Enrich before transaction, never await network calls inside a transaction
      const enriched = { ...order, updatedAt: new Date().toISOString() };
      const req = store.put(enriched);

      req.onsuccess = () => resolve(req.result);
      tx.onerror = () => reject(tx.error);
    });
  }

  /**
   * Query orders by userId and status using a compound index.
   * @param {number} userId
   * @param {string} status
   * @returns {Promise<object[]>}
   */
  findByUserAndStatus(userId, status) {
    return new Promise((resolve, reject) => {
      const tx = this.#db.transaction('orders', 'readonly');
      const index = tx.objectStore('orders').index('by_user_status');

      // IDBKeyRange for compound index, exact match on [userId, status]
      const range = IDBKeyRange.only([userId, status]);
      const req = index.getAll(range);

      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }

  /**
   * Delete an order by primary key.
   */
  delete(id) {
    return new Promise((resolve, reject) => {
      const tx = this.#db.transaction('orders', 'readwrite');
      const req = tx.objectStore('orders').delete(id);
      req.onsuccess = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
}

6. Cursors: iterating large amounts of data efficiently

When you want to process all records of an object store or index without loading them all into memory at once, cursors are the right tool. An IndexedDB cursor points to a single record and moves to the next one via cursor.continue(). This always loads only one record at a time, O(1) memory usage for stores of any size. Cursors can also delete or update records while iterating, which enables batch updates without loading all IDs in advance.

Cursors can be combined with a direction (next, prev) and an IDBKeyRange to navigate through a subset of the store or index. This enables paginated queries in IndexedDB: set the cursor at the start of the page, read a defined number of records, and use the last read key as the cursor start point on the next page request. Keyset pagination is considerably more efficient than offset pagination for IndexedDB, because no full scan up to the offset is required.

7. Promise wrappers for ergonomic IndexedDB usage

The native IndexedDB API is event based: every operation is an IDBRequest with onsuccess and onerror callbacks. This leads to nested, hard-to-read code. The ergonomic solution: write a thin promise wrapper that wraps every operation in a promise, or use a library such as Dexie.js, which provides a complete, cleanly typed promise API on top of IndexedDB.

Dexie.js is the most popular abstraction for IndexedDB: it offers declarative schema management, promise-based CRUD operations, an extensive query API with .where(), .equals(), .between(), and full TypeScript support. The abstraction is thin enough that you can fall back to the native IndexedDB API when needed. For most projects, Dexie is the best balance between ergonomics and flexibility, without the overhead of heavyweight ORM libraries.


// Dexie.js, an ergonomic IndexedDB wrapper with typed schema
import Dexie from 'dexie';

// Define schema with TypeScript-style type hints in comments
const db = new Dexie('mironsoft-app');

db.version(3).stores({
  users: '++id, &email, name',        // ++ = autoincrement, & = unique index
  orders: 'id, [userId+status], createdAt',
  sync_queue: '++queueId, entity',
});

// Typed helper, clean async/await API
async function getUserByEmail(email) {
  return db.users.where('email').equals(email).first();
}

async function getPendingOrdersForUser(userId) {
  return db.orders
    .where('[userId+status]')
    .equals([userId, 'pending'])
    .sortBy('createdAt');
}

// Batch import with transaction, atomic: all or nothing
async function importOrders(orders) {
  return db.transaction('rw', db.orders, async () => {
    for (const order of orders) {
      await db.orders.put({
        ...order,
        syncedAt: new Date().toISOString(),
      });
    }
  });
}

// Pagination with Dexie, keyset pagination via offset
async function getOrderPage(pageSize, offset) {
  return db.orders
    .orderBy('createdAt')
    .reverse()
    .offset(offset)
    .limit(pageSize)
    .toArray();
}

8. Offline sync strategies with the Service Worker

The full strength of IndexedDB unfolds when combined with the Service Worker for offline-first apps. The basic principle: when the user is offline, write operations are not sent directly to the API but stored as pending operations in a sync queue inside IndexedDB. As soon as network connectivity returns, the Service Worker processes the queue and synchronizes the data with the server. The Service Worker can use the Background Sync API for this, which allows synchronization to happen even when the app is not open.

Conflict handling is the hardest challenge in offline sync. If the same record was changed offline on two devices, a merge strategy has to be chosen: last-write-wins (simple, but lossy), three-way merge (complex, but complete), or version-vector-based merging (for distributed systems). For most apps, server-based conflict resolution with timestamps is practical: the client sends along the last known server timestamp; if the server has a newer version, it reports the conflict back and the client can either ask the user to resolve it or merge automatically.

9. Comparing browser storage options

Choosing the right browser storage technology depends on the amount of data, its structure and the query requirements. IndexedDB is the right choice for structured, queryable data in large volumes, but for simple use cases it is overkill.

Storage option Capacity API type Queries Ideal for
localStorage 5 to 10 MB Synchronous Key/value only Settings, tokens
sessionStorage 5 to 10 MB Synchronous Key/value only Tab-specific data
Cache API GB range Async / Promise Request/response pair HTTP responses, assets
IndexedDB GB range Async / Event Indexes, ranges, cursors Structured data, offline apps
OPFS GB range Async / Sync (worker) File system SQLite-WASM, files

A newer approach: SQLite as a WASM module with the Origin Private File System (OPFS) as the storage backend. This enables real SQL inside the app, including JOINs, complex aggregation and full transaction semantics. However, IndexedDB remains the most stable cross-platform choice without a WebAssembly dependency. For apps that need maximum SQL compatibility, SQLite-WASM is an interesting alternative; for most offline-first apps, IndexedDB with Dexie.js is fully sufficient.

Mironsoft

Progressive web apps, offline-first architecture and browser storage

Need an offline-first app for your users?

We build PWAs with IndexedDB-based offline data storage, Service Worker sync and conflict resolution, so your app works fully even without a network connection.

PWA development

IndexedDB data storage, Service Worker and Background Sync for complete offline capability

Sync architecture

Bidirectional synchronization between IndexedDB and backend API with conflict resolution

Storage migration

Migrating localStorage-based apps to IndexedDB with a versioned schema and Dexie.js

10. Summary

IndexedDB is the browser's offline database: asynchronous, structured, capable of gigabyte scale and with full transaction semantics. The key concepts: object stores for data storage, versioned schema management via onupgradeneeded, transactions with ACID guarantees, indexes for O(log n) queries, and cursors for memory-efficient traversal of large datasets. The event-based native API is lifted to a modern async/await level through promise wrappers or Dexie.js.

Combined with the Service Worker and the Background Sync API, IndexedDB becomes the heart of offline-first apps. Write operations land in a sync queue, are applied locally right away, and are synchronized automatically once network connectivity returns. This gives users a responsive app experience regardless of network quality. For projects that need SQL query power, SQLite-WASM over OPFS is an emerging alternative; for the majority of offline-first use cases, IndexedDB with Dexie.js is the most pragmatic and robust solution.

IndexedDB, the essentials at a glance

Schema & versioning

Schema changes only inside onupgradeneeded. Incremental migrations per version number, important for apps running different schema versions on different devices.

Transactions

Never await a network request inside a transaction. Prepare all data before starting the transaction, since it commits automatically at the end of the event loop slot.

Indexes & queries

Create indexes for every query except primary key lookups. IDBKeyRange for range queries. Compound indexes for combined filtering, no full scan required.

Dexie.js & offline sync

Dexie.js as an ergonomic abstraction. Sync queue in IndexedDB for offline operations. Service Worker plus Background Sync for automatic synchronization when the network returns.

11. FAQ: JavaScript IndexedDB

1How much storage can IndexedDB use?
Typically 50% of the free device storage, several gigabytes. Use navigator.storage.persist() to request persistent storage that is not deleted automatically.
2Why is IndexedDB so complex?
Asynchronous plus transaction based means more boilerplate. Dexie.js reduces the complexity to a modern async/await level. The complexity brings gigabyte capacity and ACID transactions.
3Is data lost when clearing the cache?
Yes, when deleting "all time". Use navigator.storage.persist() to request persistent storage, protected from automatic browser cleanup.
4IndexedDB in a Service Worker?
Yes, fully accessible. The Service Worker reads the sync queue from IndexedDB and sends pending operations automatically once network connectivity is available.
5IndexedDB vs. Cache API?
Cache API: caches HTTP responses and assets. IndexedDB: structured application data with query capabilities. Both complement each other in offline-first apps.
6Schema migrations in IndexedDB?
Inside onupgradeneeded with if (oldVersion < N) checks. Incremental migrations, each version builds on the previous one. Schema changes are only possible here.
7No awaiting network calls in transactions?
await fetch() hands control back to the event loop, so the transaction commits automatically. After that: TransactionInactiveError. Prepare all data before the transaction.
8What are cursors good for?
O(1) memory usage for stores of any size, only ever one record in memory at a time. Ideal for batch exports, incremental processing and keyset pagination.
9Dexie.js or the native IndexedDB API?
Dexie.js for most projects: promise API, TypeScript support, elegant query syntax. Native API only for minimal dependencies or specific transaction control.
10Resolving offline sync conflicts?
Timestamp-based last-write-wins with server authority as a pragmatic solution. Client sends the last known timestamp, server reports conflicts back.