Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

JavaScript IndexedDB for Client Side Storage

By Kokil Thapa | Last reviewed: September 2026

Your web app needs to store structured data locally—draft forms, cached API responses, uploaded documents, or offline queue items. JavaScript IndexedDB for client side storage is the browser-native answer when localStorage is too small and too slow. It runs asynchronously, supports indexes and transactions, and holds far more data than key-value APIs. This guide walks through opening databases, reading and writing records, migrating schemas, and pairing IndexedDB with service workers for offline apps on real production projects.

What is JavaScript IndexedDB for client side storage and when should you use it?

IndexedDB is a low-level, event-driven API built into modern browsers. It stores JavaScript objects, Blobs, and ArrayBuffers inside a per-origin database. Unlike localStorage, every operation is non-blocking. That matters on mobile devices where synchronous I/O freezes the main thread.

On a production Laravel booking app I maintain, we cache itinerary JSON and user draft selections in IndexedDB. The server remains the source of truth. IndexedDB simply keeps the UI responsive when connectivity drops on trekking routes. For similar full-stack work, see our web development services in Nepal.

Reach for IndexedDB when you need any of the following:

  • Structured records with fields you want to query by index
  • Storage above the ~5 MB localStorage ceiling (browsers often grant IndexedDB hundreds of MB or more)
  • Binary data such as PDF previews or image thumbnails
  • Atomic read/write groups via transactions
  • Offline-first sync queues that replay when the network returns

Skip IndexedDB for tiny flags, session tokens, or a handful of strings. Use sessionStorage or HTTP-only cookies for those cases instead.

IndexedDB Client Storage StackWeb App UIVue / Alpine / JSIndexedDBObject storesREST APIServer sourcePer-Origin DatabaseordersdraftsmediaIndexes enable fast lookups by status, date, or userIdService Worker can read same origin stores
JavaScript IndexedDB for client side storage sits between your UI layer and server API, holding structured object stores per origin.

How do you open a database and create object stores in IndexedDB?

Every IndexedDB session starts with indexedDB.open(). The call is asynchronous. You attach handlers for onsuccess, onerror, and critically onupgradeneeded. Schema changes only happen inside that upgrade event.

Step 1: Open the database

const DB_NAME = 'app-cache';
const DB_VERSION = 1;

function openAppDb() {
  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;

      if (!db.objectStoreNames.contains('bookings')) {
        const store = db.createObjectStore('bookings', { keyPath: 'id' });
        store.createIndex('by_status', 'status', { unique: false });
        store.createIndex('by_updated', 'updatedAt', { unique: false });
      }
    };
  });
}

Store your JSON payloads as plain objects. Validate them with the same rules you use server-side. A JSON formatter helps inspect cached records during development.

Step 2: Choose key strategies

You have two primary options for record keys:

  1. keyPath — the object must contain that field; IndexedDB uses it as the primary key
  2. autoIncrement — the database assigns numeric keys; useful for log-style tables

Composite lookups belong in indexes, not duplicate keys. Create an index on email, status, or createdAt depending on your query patterns.

The raw API is verbose. Many teams wrap it with the idb helper library from Jake Archibald, or use Dexie.js for richer query syntax. Both compile to the same underlying API described in the MDN IndexedDB documentation.

How do you read and write data with IndexedDB transactions?

IndexedDB groups operations into transactions scoped to one or more object stores. A transaction mode controls concurrency:

  • readonly — multiple readers allowed
  • readwrite — exclusive write access to touched stores

Transactions auto-commit when the event loop goes idle with no pending requests. Never await unrelated work inside a transaction callback. That pattern causes subtle commit failures.

Writing a record

async function saveBooking(db, booking) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('bookings', 'readwrite');
    const store = tx.objectStore('bookings');

    store.put({
      id: booking.id,
      status: booking.status,
      payload: booking,
      updatedAt: Date.now(),
    });

    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

Reading by index

async function getPendingBookings(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('bookings', 'readonly');
    const index = tx.objectStore('bookings').index('by_status');
    const request = index.getAll('pending');

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

Promises make the flow readable. If you prefer async/await throughout, read our notes on JavaScript async/await common pitfalls first. Mixing async gaps inside transaction callbacks is a frequent source of bugs.

IndexedDB Transaction Flowopen()transactionreadwriteput / geton storecommitError anywhere → tx.onerror → abort (no partial writes)Rules That Prevent Production BugsKeep transactions short — no fetch() or setTimeout insideOne transaction per logical unit of work
Every IndexedDB write runs inside a transaction that commits atomically or aborts on any error.

On legal-tech portals with document uploads, I store metadata in IndexedDB and Blobs for unsent files. The pattern mirrors server-side queues. Files sync to object storage once connectivity returns, similar to patterns in Laravel file uploads with S3, R2, and local storage.

What is the difference between IndexedDB, localStorage, and sessionStorage?

All three APIs persist data in the browser. They solve different problems. Picking the wrong one creates performance pain or storage limits later.

FeatureIndexedDBlocalStoragesessionStorage
Data modelStructured objects, Blobs, indexesString key-value onlyString key-value only
API styleAsynchronous (event-driven)Synchronous (blocks main thread)Synchronous
Typical size limitLarge (often 50%+ of disk quota)~5 MB per origin~5 MB per tab
TransactionsYes — atomic read/write groupsNoNo
Indexed queriesYes — via keyPath and indexesNo — manual JSON parse loopsNo
Service Worker accessYesYesNo (tab-scoped)
Best forOffline apps, caches, drafts, mediaUI prefs, small flagsWizard state within one tab

localStorage looks simpler. A loop over 2,000 JSON strings to find one record will jank the UI. IndexedDB pays setup cost upfront but scales with data volume. For type-safe wrappers around async storage code, see TypeScript for JavaScript developers.

Storage Capacity vs Query PowersessionStorage~5 MBTab onlyNo indexeslocalStorage~5 MBSync APIString valuesIndexedDB100+ MBIndexed queriesAsync + Blobs
JavaScript IndexedDB for client side storage outscales localStorage when your app caches structured or binary data.

How do you handle IndexedDB versioning, migrations, and errors in production?

Schema changes require bumping DB_VERSION. The browser fires onupgradeneeded with the old and new version numbers. Add stores and indexes there. You cannot change keyPaths on an existing store—you must create a new store and migrate data manually.

Migration pattern

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

  if (oldVersion < 1) {
    db.createObjectStore('bookings', { keyPath: 'id' });
  }

  if (oldVersion < 2) {
    const store = tx.objectStore('bookings');
    store.createIndex('by_user', 'userId', { unique: false });
  }

  if (oldVersion < 3) {
    db.createObjectStore('sync_queue', { keyPath: 'id', autoIncrement: true });
  }
};

Test migrations with a matrix of old versions. Clear-site-data in DevTools alone is not enough. Keep a version manifest in your app config. Log upgrade failures to your monitoring endpoint.

Common production errors

  • QuotaExceededError — disk full or user denied storage; prune old cache entries
  • VersionError — two tabs opened different versions simultaneously; reload the stale tab
  • InvalidStateError — transaction closed before requests finished; shorten async gaps
  • Private browsing edge cases — Safari may evict data aggressively; always handle onerror

Wrap storage access in a single module. Export getDb(), cacheSet(), and cacheGet() helpers. That isolation simplifies testing and future swaps to the Cache API for static assets. Our testing and optimization services often catch storage regressions during QA passes on slow networks.

For eCommerce carts that survive refresh, IndexedDB holds line items until checkout completes. WooCommerce and custom Laravel carts on projects like Quick And Easy Nepalese Grocery still rely on server sessions for payment authority. Client storage is a convenience layer, not a ledger.

How do you debug IndexedDB and build offline-capable web apps?

Chrome DevTools exposes an Application → IndexedDB tree. Firefox and Safari offer similar panels. You can inspect object stores, edit records, and delete databases during development.

Offline sync queue example

A practical offline pattern stores failed POST payloads in a sync_queue store. When navigator.onLine flips true, a service worker or page script drains the queue.

async function enqueueSyncAction(db, action) {
  const tx = db.transaction('sync_queue', 'readwrite');
  tx.objectStore('sync_queue').add({
    url: action.url,
    method: action.method,
    body: action.body,
    createdAt: Date.now(),
  });
  return txComplete(tx);
}

async function flushSyncQueue(db) {
  const items = await getAllSyncItems(db);
  for (const item of items) {
    const res = await fetch(item.url, {
      method: item.method,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item.body),
    });
    if (res.ok) await deleteSyncItem(db, item.id);
  }
}

Debounce online/offline handlers so you do not hammer the API. Our guide on JavaScript debouncing and throttling covers the timing logic.

Pair IndexedDB with the Cache API for static shells. The service worker serves cached HTML and JS. IndexedDB holds dynamic user data. Together they power offline booking flows on travel sites such as Adventure Third Pole Trek.

Offline Queue with IndexedDBUser actionofflinesync_queueonline eventflush loopREST APIProduction ChecklistUse idempotency keys on server for replay safetyExpire stale queue items after 7–30 daysNever store raw payment card data client-sideEncrypt sensitive fields if policy requires it
Queue failed writes in IndexedDB during offline periods, then replay them when connectivity returns.

Security and privacy notes

IndexedDB is origin-scoped. Any script on your domain can read it. XSS therefore exposes cached data. Sanitize output, use Content Security Policy, and avoid storing secrets or PII you do not need offline.

The W3C specification defines the storage model in the IndexedDB standard. Browser vendors implement slightly different quota prompts. Always request navigator.storage.persist() when your app is installed as a PWA and must retain data long-term.

On law-firm intake forms, I cache half-filled applications locally. Users appreciate not losing work. We still POST to Laravel with CSRF protection once they submit. That split keeps Court Marriage In Nepal forms usable on unstable mobile networks without treating the browser as a database of record.

Need structured API design for sync endpoints? Our API development services cover idempotent writes and conflict resolution. For performance audits that include client storage review, see speed optimization services.

Key Takeaways

  • Use JavaScript IndexedDB for client side storage when you need structured, indexed, high-volume data—not for tiny string flags.
  • Create and migrate schemas only inside onupgradeneeded; bump DB_VERSION for every schema change.
  • Keep transactions short; never await unrelated promises inside a readwrite transaction.
  • Pair IndexedDB with service workers for offline-first apps; keep the server as the authoritative source of truth.
  • Handle QuotaExceededError and private-mode eviction gracefully with pruning and user messaging.
  • Wrap the raw API in a small storage module—or use Dexie.js or idb—to reduce boilerplate and bugs.

People Also Ask

Is IndexedDB supported in all modern browsers?

Yes. Chrome, Firefox, Safari, and Edge all support IndexedDB. Mobile WebViews on current Android and iOS versions include it as well. Feature-detect with 'indexedDB' in window before opening a database. Provide a degraded experience for the rare legacy environment.

Can IndexedDB store files and images?

Yes. Store Blob or File objects directly in object stores. You can also persist ArrayBuffers. This makes IndexedDB suitable for document previews and image thumbnails. Large media libraries may still belong on the server or in the Cache API for static assets.

How much data can IndexedDB hold?

There is no fixed number in the spec. Browsers grant a portion of available disk space per origin—often hundreds of megabytes or more. Users may see a permission prompt on some platforms. Call navigator.storage.estimate() to inspect usage and quota during development.

Should I use IndexedDB or SQLite via WebAssembly?

IndexedDB is the native browser path with no extra binary download. WASM SQLite suits complex relational queries or porting existing SQL logic. For most web apps—caches, drafts, offline queues—IndexedDB is simpler and sufficient.

Ship Offline-Ready Apps with Confidence

JavaScript IndexedDB for client side storage is the right tool when your app must work without a constant network connection. Open a versioned database, define object stores with indexes, and wrap reads and writes in short transactions. Test migrations, cap cache size, and sync back to your API with idempotent endpoints.

If you are building an offline-capable portal, eCommerce flow, or custom PWA and want the storage layer done properly from day one, contact us or explore custom software development and e-commerce development services. You can also browse the portfolio for examples of production web systems that handle real user data responsibly.

Frequently Asked Questions

An asynchronous, transactional browser database API for structured objects, Blobs, and indexes—ideal for offline caches, drafts, and large client-side datasets.

Reach for IndexedDB when you need structured records with indexable fields, storage above the roughly 5 MB localStorage ceiling, binary data such as PDF previews, atomic read/write groups via transactions, or offline-first sync queues. Skip it for tiny flags, session tokens, or a handful of strings—use sessionStorage or HTTP-only cookies instead. On production apps I maintain, IndexedDB caches itinerary JSON and draft selections while the server remains the source of truth.

Start with indexedDB.open() using a database name and version number. Attach handlers for onsuccess, onerror, and onupgradeneeded—schema changes only happen inside the upgrade event. Inside onupgradeneeded, call createObjectStore with a keyPath or autoIncrement, then add indexes for fields you query often, such as status or updatedAt. Store JSON payloads as plain objects and validate them with the same rules you use server-side before writing.

Group every operation into a transaction scoped to one or more object stores. Use readonly mode for queries—multiple readers are allowed—or readwrite for exclusive write access. Call put() to save records and use indexes like getAll() for filtered reads. Transactions auto-commit when the event loop goes idle with no pending requests. Never await unrelated work inside a transaction callback; that pattern is a frequent source of InvalidStateError in production.

All three persist data in the browser but solve different problems. localStorage and sessionStorage are synchronous string key-value stores capped around 5 MB. IndexedDB is asynchronous, supports structured objects, Blobs, indexes, and atomic transactions, and typically grants hundreds of megabytes or more. sessionStorage is tab-scoped and inaccessible to service workers. localStorage looks simpler, but looping over thousands of JSON strings to find one record will jank the UI on mobile devices.

Bump DB_VERSION whenever your schema changes. The browser fires onupgradeneeded with old and new version numbers—create stores and indexes there only. You cannot change keyPaths on an existing store; create a new store and migrate data manually. Test migrations against a matrix of old versions because clearing site data in DevTools alone is not enough. Log upgrade failures to your monitoring endpoint and keep a version manifest in your app config.

Yes. Chrome, Firefox, Safari, and Edge support it. Mobile WebViews on current Android and iOS include it. Feature-detect with indexedDB in window before opening a database.

Yes. Store Blob or File objects or ArrayBuffers in object stores. Suitable for document previews and image thumbnails; large static media may belong on the server or Cache API.

There is no fixed spec limit. Browsers grant a portion of available disk space per origin—often hundreds of megabytes or more. Call navigator.storage.estimate() during development to inspect usage and quota.

IndexedDB is the native browser path with no extra binary download. WASM SQLite suits complex relational queries or porting existing SQL logic. For most web apps—caches, drafts, offline queues—IndexedDB is simpler and sufficient. I've used IndexedDB on booking and legal-tech portals where query patterns are straightforward index lookups, not multi-table joins. Only reach for WASM SQLite when you genuinely need SQL semantics you cannot express with object stores and indexes.

QuotaExceededError means disk is full or the user denied storage—prune old cache entries and surface a clear message. VersionError occurs when two tabs open different database versions simultaneously—reload the stale tab. InvalidStateError usually means a transaction closed before requests finished—shorten async gaps inside callbacks. Safari private browsing may evict data aggressively, so always handle onerror and never treat the browser as your only copy of critical data.

Chrome DevTools exposes an Application panel with an IndexedDB tree where you can inspect object stores, edit records, and delete databases. Firefox and Safari offer similar panels. Wrap storage access in a single module exporting helpers like getDb(), cacheSet(), and cacheGet()—that isolation simplifies testing and makes it easier to inspect cached records during development. Validate JSON payloads with the same rules you use server-side before writing them to object stores.

Store failed POST payloads in a sync_queue object store during offline periods. When navigator.onLine becomes true, drain the queue with fetch and delete successful items. Debounce online and offline handlers so you do not hammer your API. Pair IndexedDB with the Cache API and a service worker—the worker serves cached HTML and JS while IndexedDB holds dynamic user data. Keep your server as the authoritative source of truth and design sync endpoints to be idempotent.

IndexedDB is origin-scoped, meaning any script on your domain can read it. XSS therefore exposes cached data. Sanitize output, enforce Content Security Policy, and avoid storing secrets or PII you do not genuinely need offline. On law-firm intake forms I cache half-filled applications locally for usability on unstable networks, but final submission still goes through Laravel with CSRF protection. Treat client storage as a convenience layer, not a database of record.

The raw API is verbose and event-driven. Many teams wrap it with the idb helper library from Jake Archibald or use Dexie.js for richer query syntax—both compile to the same underlying browser API described in MDN documentation. Either option reduces boilerplate around Promises, transactions, and open calls. Wrap whichever approach you choose in a small storage module so you can swap implementations or fall back to the Cache API for static assets without rewriting your UI layer.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: