← Back to Blog

Managing Large-Scale Browser Data with IndexedDB

📅 July 22, 2026⏱ 11 min read🏷 Web Dev

Modern web applications require robust client-side storage mechanisms to support offline functionality, improve performance, and manage large volumes of structured data. While traditional solutions like cookies and LocalStorage serve simple use cases well, they fall short when handling complex queries, binary data, or datasets exceeding a few megabytes. Enter IndexedDB—a low-level API for client-side storage of significant amounts of structured data, including files and blobs. It is a transactional, object-oriented database system built directly into the browser, enabling developers to build resilient, offline-first web applications.

Understanding how to leverage IndexedDB is crucial for modern front-end developers. Unlike relational databases that use SQL, IndexedDB is a NoSQL database. Instead of tables with rows and columns, it stores data as JavaScript objects in "object stores." In this guide, we will explore the core concepts of IndexedDB, walk through step-by-step implementation examples, examine how to run queries using indexes and cursors, and discuss production-grade best practices.

IndexedDB vs. LocalStorage: When to Use Which?

Before diving into the technical details of IndexedDB, it is helpful to compare it with LocalStorage, the most common alternative for client-side storage. Understanding their differences will help you choose the right tool for your specific application requirements.

Core Concepts of IndexedDB

To work effectively with IndexedDB, you must understand its core architectural building blocks. Because the API is low-level and event-driven, these concepts form the foundation of every database operation you will write.

1. The Database

An IndexedDB database is the highest-level container. An origin (combination of protocol, domain, and port) can have multiple databases, each identified by a unique name. Databases are versioned using integer values (e.g., 1, 2, 3). Changes to the database structure—such as creating or deleting object stores—can only occur during a version upgrade transition.

2. Object Stores

Object stores are the equivalent of tables in relational databases or collections in MongoDB. They contain the records (JavaScript objects) you want to store. Each object store must have a way to uniquely identify its records, which is done using a primary key path or a key generator.

3. Keys and Key Paths

Every record in an object store must have a unique key. You can define a key path (a property name within the stored object, such as id or email) that IndexedDB will use as the primary key. Alternatively, you can use a key generator (auto-incrementing integer) to automatically assign keys to new objects.

4. Transactions

All database reads and writes in IndexedDB must happen within a transaction. Transactions guarantee data integrity; if one step within a transaction fails, the entire transaction is rolled back, preventing partial data writes. Transactions can be read-only (readonly) or read-write (readwrite). To maximize performance, use read-only transactions unless you are actively writing data.

5. Indexes

An index is a specialized object store that lets you look up records in a target object store by a property other than the primary key. For example, if you store user profiles in an object store keyed by userId, you can create an index on the email or lastName property to quickly query users by those values.

6. Cursors

When you need to retrieve multiple records or iterate through a dataset, you use a cursor. A cursor traverses the database record by record, allowing you to inspect, modify, or delete entries sequentially without loading the entire dataset into memory at once.

Working with IndexedDB: Step-by-Step Implementation

Let us walk through a practical implementation of IndexedDB. In this example, we will build a database to manage a list of tasks or "todo" items, illustrating how to initialize the database, execute transactions, perform CRUD operations, and clean up resources.

Initializing and Versioning the Database

The first step is to open a connection to the database using indexedDB.open(). This method takes the database name and a version number. It returns an IDBOpenDBRequest object, which fires events depending on the success or failure of the connection.

// Define database details
const dbName = "TaskManagerDB";
const dbVersion = 1;

// Open connection
const request = indexedDB.open(dbName, dbVersion);

// Handle schema updates
request.onupgradeneeded = (event) => {
    const db = event.target.result;
    console.log(`Upgrading database to version ${db.version}`);

    // Create an object store named "tasks" with an auto-incrementing key
    if (!db.objectStoreNames.contains("tasks")) {
        const store = db.createObjectStore("tasks", { keyPath: "id", autoIncrement: true });
        
        // Create indexes for efficient querying
        store.createIndex("by_status", "status", { unique: false });
        store.createIndex("by_priority", "priority", { unique: false });
    }
};

// Handle successful connection
request.onsuccess = (event) => {
    const db = event.target.result;
    console.log("Database initialized successfully");
    // You can now perform database operations using this db instance
};

// Handle errors
request.onerror = (event) => {
    console.error("Database failed to open:", event.target.error);
};

The onupgradeneeded event is the only place where you can alter the database schema (e.g., calling createObjectStore or createIndex). It fires when the database is first created or when you specify a version number higher than the existing installed version.

Adding Records (Create)

To add a record, we must initiate a readwrite transaction, access the object store, and invoke the add() method. The transaction automatically commits once all requests complete and no further references remain active.

function addTask(db, task) {
    // 1. Start a transaction on the "tasks" store with readwrite permissions
    const transaction = db.transaction(["tasks"], "readwrite");
    const store = transaction.objectStore("tasks");

    // 2. Add the task object to the store
    const request = store.add(task);

    // 3. Handle success or failure of this specific request
    request.onsuccess = () => {
        console.log("Task added successfully, assigned ID:", request.result);
    };

    request.onerror = (event) => {
        console.error("Error adding task:", event.target.error);
    };

    // 4. Optionally listen to the transaction completion
    transaction.oncomplete = () => {
        console.log("Transaction completed successfully.");
    };

    transaction.onabort = () => {
        console.warn("Transaction aborted.");
    };
}

// Example usage:
// addTask(dbInstance, { title: "Write documentation", status: "pending", priority: "high" });

Retrieving Records (Read)

Reading data can be done using a primary key lookup. Since reading does not modify the data, we use a readonly transaction to optimize performance and prevent locking the database.

function getTaskById(db, taskId) {
    const transaction = db.transaction(["tasks"], "readonly");
    const store = transaction.objectStore("tasks");
    const request = store.get(taskId);

    request.onsuccess = (event) => {
        const task = event.target.result;
        if (task) {
            console.log("Task retrieved:", task);
        } else {
            console.log("No task found with ID:", taskId);
        }
    };

    request.onerror = (event) => {
        console.error("Error retrieving task:", event.target.error);
    };
}

Updating Records (Update)

To update an existing record, use the put() method. If the key already exists, put() replaces the existing object with the new object. If the key does not exist, it creates a new record (similar to an upsert operation).

function updateTask(db, updatedTask) {
    const transaction = db.transaction(["tasks"], "readwrite");
    const store = transaction.objectStore("tasks");
    const request = store.put(updatedTask);

    request.onsuccess = () => {
        console.log("Task updated successfully");
    };

    request.onerror = (event) => {
        console.error("Error updating task:", event.target.error);
    };
}

Deleting Records (Delete)

Removing items is straightforward. Use the delete() method and pass the primary key of the record you want to erase.

function deleteTask(db, taskId) {
    const transaction = db.transaction(["tasks"], "readwrite");
    const store = transaction.objectStore("tasks");
    const request = store.delete(taskId);

    request.onsuccess = () => {
        console.log("Task deleted successfully");
    };

    request.onerror = (event) => {
        console.error("Error deleting task:", event.target.error);
    };
}

Querying Data with Cursors and Key Ranges

Retrieving data by ID is simple, but practical applications often require querying based on properties, retrieving all items, or sorting records. This is where indexes, cursors, and key ranges play an essential role.

Using Cursors to Retrieve All Records

A cursor loops through records in order. We open a cursor using openCursor() and iterate over results by calling cursor.continue() in the success callback.

function getAllTasks(db) {
    const transaction = db.transaction(["tasks"], "readonly");
    const store = transaction.objectStore("tasks");
    const request = store.openCursor();
    const tasks = [];

    request.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor) {
            // Push current record to our array
            tasks.push(cursor.value);
            // Move to the next record, triggering onsuccess again
            cursor.continue();
        } else {
            // Cursor is null, meaning we have reached the end of the store
            console.log("All tasks retrieved:", tasks);
        }
    };

    request.onerror = (event) => {
        console.error("Cursor error:", event.target.error);
    };
}

Querying Through an Index

Instead of reading the entire database and filtering manually in JavaScript, we can query our pre-defined indexes. This is substantially faster on large datasets because it uses binary search trees under the hood.

function getTasksByStatus(db, statusValue) {
    const transaction = db.transaction(["tasks"], "readonly");
    const store = transaction.objectStore("tasks");
    const index = store.index("by_status");
    
    // Open cursor restricted to a specific status value
    const request = index.openCursor(IDBKeyRange.only(statusValue));
    const tasks = [];

    request.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor) {
            tasks.push(cursor.value);
            cursor.continue();
        } else {
            console.log(`Tasks with status "${statusValue}":`, tasks);
        }
    };
}

Understanding IDBKeyRange

The IDBKeyRange object allows you to construct complex search boundaries for your cursors. It supports several helper methods:

Advanced IndexedDB Patterns and Best Practices

Working directly with the native IndexedDB API can lead to verbose, nested callback code. In production environments, utilizing design patterns and modern wrappers can help keep your code clean, readable, and maintainable.

1. Wrapping IndexedDB with Promises

Since IndexedDB was designed before Promises became a standard part of JavaScript, it relies heavily on event handlers (onsuccess and onerror). Wrapping these inside Promises allows you to use clean async/await syntax.

// Example of wrapping open request in a Promise
function openDatabase(name, version) {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open(name, version);
        request.onsuccess = () => resolve(request.result);
        request.onerror = () => reject(request.error);
        request.onupgradeneeded = (e) => {
            const db = e.target.result;
            if (!db.objectStoreNames.contains("tasks")) {
                db.createObjectStore("tasks", { keyPath: "id", autoIncrement: true });
            }
        };
    });
}

// Consuming the Promise-based API
async function initApp() {
    try {
        const db = await openDatabase("TaskManagerDB", 1);
        console.log("Connected using Promises!");
    } catch (error) {
        console.error("Failed to connect:", error);
    }
}

Alternatively, consider using lightweight wrapper libraries like idb (written by Jake Archibald), which turns the native event-driven API into a modern Promise-based one without adding heavy runtime overhead.

2. Dealing with Database Upgrades

When deploying updates to your application, you may need to modify the structure of your database. Since IndexedDB does not allow modifying stores outside of the onupgradeneeded lifecycle event, schema evolution must be managed carefully. Always check the oldVersion property of the upgrade event to conditionally run migrations.

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

    if (oldVersion < 1) {
        // Run migration for version 1
        const store = db.createObjectStore("tasks", { keyPath: "id", autoIncrement: true });
        store.createIndex("by_status", "status");
    }
    if (oldVersion < 2) {
        // Run migration for version 2 (e.g., adding a new index or object store)
        const transaction = event.target.transaction;
        const store = transaction.objectStore("tasks");
        store.createIndex("by_dueDate", "dueDate");
    }
};

3. Managing Quotas and Storage Limits

IndexedDB stores data on the user's device, meaning it is subject to disk space limitations. Browsers allocate storage dynamically based on available space. If the device runs low on storage, the browser may automatically clear "temporary" storage (which includes IndexedDB). To prevent unexpected data loss in critical applications, you can request persistent storage.

if (navigator.storage && navigator.storage.persist) {
    navigator.storage.persist().then((granted) => {
        if (granted) {
            console.log("Storage persistence granted. The browser will not clear this database automatically.");
        } else {
            console.log("Storage persistence denied. Data might be cleared under low disk conditions.");
        }
    });
}

You can also inspect the current storage usage and remaining quota using navigator.storage.estimate(), allowing you to warn users when storage space is running low.

Summary of Key Tips for Developers

IndexedDB is a cornerstone of modern, high-performance web development. By mastering its asynchronous design, leveraging transaction safety, and structuring data with indexes and key ranges, you can build web applications that rival native desktop and mobile platforms in speed, capacity, and offline resilience.