Skip to content
WhatsApp
AI StudioPricing
Development16 min read2026-09-10

Browser Storage at Scale: Mastering IndexedDB with Dexie.js for Offline Client Caching

Break free from localStorage 5MB limits. Learn how to architect multi-gigabyte client databases with Dexie.js, compound indexes, transaction rollbacks, schema migrations, and storage eviction safety.

Marcus Vance
Staff Infrastructure Engineer
Browser Storage at Scale: Mastering IndexedDB with Dexie.js for Offline Client Caching

The Limitations of localStorage and the Power of IndexedDB

Most web developers start with localStorage for client persistence. However, localStorage is synchronous (blocking the browser's main thread during disk I/O), strictly capped at 5MB across all domains, and limited to stringified key-value pairs without indexing or query capabilities.

For modern web applications—such as offline-first productivity suites, Figma-style canvases, CRM pipelines, and local AI caching—localStorage is completely inadequate.

IndexedDB is an asynchronous, transactional, object-oriented database built natively into every modern browser. It can store gigabytes of structured data, indices, binary Blobs, and typed arrays without freezing UI rendering.

However, the raw native IndexedDB API is notoriously verbose, relying on cumbersome DOM event callbacks. Dexie.js provides a clean, promise-based TypeScript wrapper that transforms IndexedDB into an elegant database layer.

Pro Tip: IndexedDB operations are asynchronous and run off the main thread, ensuring smooth 60 FPS animations even while saving 10,000 records to local disk.

Defining Type-Safe Schemas and Compound Indexes with Dexie

With Dexie.js, declare your database stores and indexed properties cleanly. Only index properties you intend to filter, sort, or query on; indexing every property wastes disk storage and slows down write operations.

Support compound indexes (such as [organizationId+status]) for high-speed multi-criteria lookups.

db.ts (Dexie Type-Safe Schema Definition)
typescript
import Dexie, { type Table } from "dexie";

export interface ProjectRecord {
  id: string;
  organizationId: string;
  name: string;
  status: "draft" | "active" | "archived";
  updatedAt: number;
  payload: Record<string, any>;
}

export class AppDatabase extends Dexie {
  public projects!: Table<ProjectRecord, string>;

  constructor() {
    super("EnterpriseClientDB");

    // Version 1 Schema
    this.version(1).stores({
      // Primary key: id. Indexes: organizationId, status, updatedAt, compound [organizationId+status]
      projects: "id, organizationId, status, updatedAt, [organizationId+status]",
    });
  }
}

export const db = new AppDatabase();

High-Speed Bulk Writes and Atomic Transactions

When syncing 5,000 records from the backend API during initial login, inserting records one-by-one via individual promises is disastrously slow. Use Dexie's bulkPut() and atomic transactions.

bulkPut() executes in a single native IDBTransaction, storing thousands of records in under 80 milliseconds. If any record fails validation, the entire transaction rolls back cleanly.

syncService.ts (Transactional Bulk Ingestion)
typescript
import { db, type ProjectRecord } from "./db";

export async function syncProjectsFromApi(apiData: ProjectRecord[]) {
  const startTime = performance.now();

  await db.transaction("rw", db.projects, async () => {
    // Bulk upsert all incoming records in a single database transaction
    await db.projects.bulkPut(apiData);
  });

  console.log(`Synced ${apiData.length} records in ${(performance.now() - startTime).toFixed(1)}ms`);
}

export async function getActiveOrgProjects(orgId: string) {
  // Query using high-speed compound index
  return await db.projects
    .where("[organizationId+status]")
    .equals([orgId, "active"])
    .sortBy("updatedAt");
}

Handling Schema Evolution and Version Migrations

As software evolves, database schemas change. Dexie handles migrations gracefully through sequential version increments.

When upgrading from version 1 to version 2, Dexie preserves existing user data while running upgrade callback transforms to reshape records or backfill new default columns.

migrations.ts (Non-Destructive Database Upgrades)
typescript
// Version 2 Migration: Rename column and add new composite index
db.version(2)
  .stores({
    projects: "id, organizationId, status, updatedAt, [organizationId+status], priority",
  })
  .upgrade((tx) => {
    // Upgrade callback transforms existing stored records
    return tx
      .table("projects")
      .toCollection()
      .modify((project) => {
        // Backfill default priority if missing
        project.priority = project.priority || "medium";
      });
  });

Storage Quota Management and Eviction Safety (navigator.storage)

By default, browsers treat IndexedDB as 'Best Effort' storage. If a user's hard drive runs low on free disk space, Safari and Chrome can automatically purge IndexedDB data to free space.

For offline-first applications holding unsynced user work, request 'Persistent Storage' via navigator.storage.persist(). Once granted, the browser will never silently delete your database without explicit user intervention.

storageQuota.ts (Requesting Storage Persistence)
typescript
export async function verifyPersistentStorage(): Promise<boolean> {
  if (navigator.storage && navigator.storage.persist) {
    const isPersisted = await navigator.storage.persisted();
    if (!isPersisted) {
      const granted = await navigator.storage.persist();
      console.log(`Storage persistence granted: ${granted}`);
      return granted;
    }
    return true;
  }
  return false;
}

export async function checkStorageEstimate() {
  if (navigator.storage && navigator.storage.estimate) {
    const { usage, quota } = await navigator.storage.estimate();
    const usageMb = ((usage || 0) / (1024 * 1024)).toFixed(1);
    const quotaMb = ((quota || 0) / (1024 * 1024)).toFixed(1);
    console.log(`Using ${usageMb} MB of ${quotaMb} MB available`);
  }
}

Reactivity Integration with React (useLiveQuery)

Dexie provides the useLiveQuery hook, which automatically monitors IndexedDB tables and triggers React component re-renders whenever underlying records are added, updated, or deleted anywhere in the app.

Because it tracks database transaction indices directly, components re-render with sub-millisecond latency when background background sync workers write new rows.

ProjectList.tsx (Reactive Live IndexedDB Hook)
tsx
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "./db";

export function ActiveProjectList({ orgId }: { orgId: string }) {
  const projects = useLiveQuery(
    () => db.projects.where("[organizationId+status]").equals([orgId, "active"]).toArray(),
    [orgId]
  );

  if (!projects) return <div className="p-4 text-xs text-muted-foreground">Reading local cache...</div>;

  return (
    <ul className="space-y-2">
      {projects.map((project) => (
        <li key={project.id} className="p-3 border rounded-lg bg-surface-1 flex justify-between items-center">
          <span className="text-sm font-medium">{project.name}</span>
          <span className="font-mono text-xs text-muted-foreground">
            {new Date(project.updatedAt).toLocaleTimeString()}
          </span>
        </li>
      ))}
    </ul>
  );
}

Conflict-Free Resolution Strategies (CRDTs and Last-Write-Wins)

When users edit data offline across two devices simultaneously, simply uploading the local IndexedDB record will overwrite remote updates. To prevent data corruption, assign each record a hybrid logical clock or monotonic updatedAt timestamp.

During sync synchronization, compare timestamps or field-level version vectors. If conflicting changes touch distinct columns, merge them automatically; if they touch the exact same property, prompt the user with an interactive diff resolution modal.

syncReconciler.ts (Last-Write-Wins Resolution)
typescript
export function reconcileRecords<T extends { id: string; updatedAt: number }>(
  local: T,
  remote: T
): { record: T; hadConflict: boolean } {
  if (local.updatedAt >= remote.updatedAt) {
    return { record: local, hadConflict: local.updatedAt !== remote.updatedAt };
  }
  return { record: remote, hadConflict: true };
}
Frequently Asked Questions

How much storage capacity can IndexedDB consume?

Modern browsers allocate up to 60% of available total hard drive space to browser storage, allowing individual origins to store tens of gigabytes if needed.

What happens if a user opens the app in Private / Incognito browsing mode?

IndexedDB operates in an ephemeral in-memory sandbox during private browsing and is completely wiped clean the moment the private window closes.

Can IndexedDB store binary files like images and PDF blobs?

Yes! You can store raw File, Blob, and ArrayBuffer objects directly in IndexedDB without converting them to base64 strings.

How do compound indexes improve query performance?

A compound index indexes multiple keys together (e.g., [teamId+role]), allowing the database engine to perform single-pass B-tree lookups instead of intersecting multiple index sets.

Is Dexie compatible with server-side rendering (SSR) in Next.js or TanStack Start?

Yes, but since IndexedDB is a browser-only window API, wrap database calls in client-side useEffect hooks or dynamic imports with ssr: false.

What is the best way to handle database index upgrades without data loss?

Use Dexie version declarations sequentially (version 1, 2, 3...). Never edit a historical version number in place, as existing client browsers will fail schema checksums.

Topics:#IndexedDB#Dexie.js#Offline First#Browser Storage#Client Database#Storage Quota#Compound Indexes#Database Migrations

Want this thinking applied to your product?

Spin up a prototype in minutes, or brief our team and get a scoped plan within 24 hours.