Keeping IndexedDB Fast in an Extension

Make IndexedDB quick in MV3 service workers and extension pages: schema and index design, batching writes in one transaction, cursors and getAll with limits, avoiding large values, connection reuse across worker restarts, and measuring.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The extension moved its 20,000 saved pages from chrome.storage.local to IndexedDB for capacity and querying — and got slower. Saving an imported batch takes eight seconds, the popup’s “recent items” list takes 400 milliseconds, and every service worker cold start reopens the database. IndexedDB can be fast, but its performance depends on choices that are easy to get wrong: one transaction per record, reading whole stores to filter in JavaScript, storing large blobs inline with metadata, and opening connections repeatedly. This guide covers the patterns that keep it fast in an extension’s service worker, offscreen documents and pages. It belongs to performance profiling and optimisation.

Where IndexedDB time goes

Each IndexedDB operation is asynchronous and runs inside a transaction. Transactions have overhead — scheduling, locking the object stores, committing to disk — so the number of transactions matters more than the number of records. Reads are fast when they use an index or key range to touch only the needed records, and slow when they load an entire store (getAll() with no range) and filter in JavaScript. Large values — page content, images — make every read of those records expensive, including reads that only needed the title. In an extension, the service worker stops and restarts, and each restart reopens the database; keeping that cheap matters for every event.

IndexedDB patterns: slow versus fastWrite batching, filtered reads, large values, pagination, connection handling and schema upgrades compared between slow and fast patterns.AreaSlowFastBulk writesOne transaction per putOne transaction, many putsFiltered readsgetAll() + JS filterIndex + key rangeLarge contentInline with metadataSeparate store by idRecent itemsLoad all, sortCursor on date index, limitConnectionsopen() per operationOne promise per context
Fewer transactions, narrower reads, smaller records.

Step-by-step: fast IndexedDB

1. Design stores and indexes for your queries

 1// db.js
 2import { openDB } from "idb";
 3
 4export const dbPromise = openDB("readable", 3, {
 5  upgrade(db, oldVersion, _new, tx) {
 6    if (oldVersion < 1) {
 7      const items = db.createObjectStore("items", { keyPath: "id" });
 8      items.createIndex("bySavedAt", "savedAt");
 9      items.createIndex("byHost", "host");
10    }
11    if (oldVersion < 2) db.createObjectStore("content", { keyPath: "id" });           // large bodies, separate
12    if (oldVersion < 3) tx.objectStore("items").createIndex("byTag", "tags", { multiEntry: true });
13  },
14});

Execution context: a module used by the service worker and extension pages. Create an index for each way you query — by date for “recent”, by host for “this site”, a multi-entry index for tags. Keep metadata (items) separate from heavy content (content), keyed by the same ID, so lists never load page bodies. The idb wrapper turns IndexedDB requests into promises without hiding transactions. See migrating IndexedDB schemas on update.

2. Reuse one connection per context

1// Every caller awaits the same promise — the database opens once per worker lifetime
2export async function getRecent(limit = 30) {
3  const db = await dbPromise;
4  // …
5}

Execution context: the service worker and pages. Opening a database is relatively expensive, especially when an upgrade check runs. A module-level promise opens it once per context lifetime; after a worker restart, the first event opens it again, once. Do not call openDB inside each function. Handle versionchange (another context upgrading) by closing and letting the next access reopen.

Importing 5,000 items in one transactionThe worker opens a single readwrite transaction on items and content, issues 5,000 puts without awaiting each one, awaits tx.done once, and reports completion; per-record transactions would have committed 5,000 times.Service workerTransactionDisktransaction(['items','content'], 'readwrite')put × 5,000 (not awaited)single committx.done
Queue many requests in one transaction; await completion once.

3. Batch writes in one transaction

 1export async function importItems(items) {
 2  const db = await dbPromise;
 3  for (let i = 0; i < items.length; i += 1000) {
 4    const tx = db.transaction(["items", "content"], "readwrite");
 5    const meta = tx.objectStore("items"), body = tx.objectStore("content");
 6    for (const it of items.slice(i, i + 1000)) {
 7      meta.put({ id: it.id, title: it.title, host: it.host, savedAt: it.savedAt, tags: it.tags });
 8      if (it.content) body.put({ id: it.id, html: it.content });
 9    }
10    await tx.done;
11    await reportProgress(i + 1000, items.length);
12  }
13}

Execution context: the service worker or an offscreen document. Issuing many put requests inside one transaction without awaiting each one lets IndexedDB pipeline them and commit once. Chunks of about a thousand keep each transaction bounded (memory, and work lost if something fails) and give natural progress points. Do not await unrelated async work (like fetch) inside a transaction — it auto-commits when no requests are pending, and later requests fail with TransactionInactiveError.

4. Read only what you need with indexes and cursors

 1export async function getRecent(limit = 30) {
 2  const db = await dbPromise;
 3  const out = [];
 4  let cursor = await db.transaction("items").store.index("bySavedAt").openCursor(null, "prev");
 5  while (cursor && out.length < limit) { out.push(cursor.value); cursor = await cursor.continue(); }
 6  return out;
 7}
 8
 9export async function getForHost(host) {
10  const db = await dbPromise;
11  return db.getAllFromIndex("items", "byHost", IDBKeyRange.only(host), 100);
12}

Execution context: any context. A reverse cursor on the date index reads exactly the newest 30 records. getAll with a key range and a count is the fastest way to read a bounded set. Both touch a small part of the store regardless of its size. Never load the whole store to sort or filter in JavaScript for a UI list.

Splitting metadata from contentThe items store holds small metadata records with indexes for date, host and tags; the content store holds large page bodies keyed by the same id; lists read only items, and opening an item reads its content by key.items storeid, title, host, savedAt, tagsIndexesbySavedAt, byHost, byTagPopup list30 small recordsopen itemcontent storeid → html (large)get(id)one recordReader viewonly when opened
Lists never pay for page bodies.

5. Keep values small and structured-clone friendly

Values are stored with the structured clone algorithm; large or deeply nested objects take longer to serialise and deserialise on every read. Store Blobs for binary data rather than base64 strings (smaller and not re-encoded), keep metadata flat, and avoid storing derived data you can recompute cheaply. See storing large blobs and files in an extension.

6. Use relaxed durability for rebuildable data

1const tx = db.transaction("searchIndex", "readwrite", { durability: "relaxed" });

Execution context: any context. Chrome supports a durability hint; "relaxed" lets the browser skip forcing writes to disk immediately, which speeds up writes of data you can rebuild (caches, search indexes) if a crash loses the last few writes. Keep the default for user data.

7. Measure operations

1export async function timed(label, fn) {
2  const t0 = performance.now();
3  try { return await fn(); } finally { console.debug(`[idb] ${label} ${Math.round(performance.now() - t0)} ms`); }
4}
5await timed("recent30", () => getRecent(30));

Execution context: development builds. Time the operations on your hot paths (popup open, search, import) with realistic data volumes — 10× your median user — and fix the slowest first. See measuring storage read and write latency.

Common mistakes

  • A transaction per record. Thousands of commits.
  • getAll() then filter. Reads the whole store.
  • Page bodies inline with metadata. Every list read gets heavy.
  • Awaiting fetch inside a transaction. It auto-commits; later requests fail.
  • Opening the database per call. Repeated open cost after every restart.

Cross-browser variation

  • Chrome / Edge: IndexedDB in service workers, offscreen documents and pages; durability hint supported.
  • Firefox: fully supported in background scripts and pages; durability hint supported in recent versions.
  • Safari: supported; storage can be evicted under pressure for extensions in some versions — request persistence where available and keep critical data in storage.local too.

Verification

  1. Import 5,000 items and confirm it completes in about a second or two on a typical machine.
  2. Time the popup’s recent-items query with 20,000 items and confirm it stays in the low milliseconds.
  3. Confirm list views never read the content store.
  4. Stop the worker and confirm the first query after restart opens the database once.

FAQ

IndexedDB or chrome.storage.local?

storage.local for settings and small state; IndexedDB for large collections, indexes and blobs.

Can content scripts use the extension’s IndexedDB?

No — content scripts use the page’s origin. Query through the service worker.

Is a wrapper library slower?

Thin wrappers like idb add negligible overhead; heavy ORMs can add more.

How do I search text quickly?

Maintain a separate token index store, or an in-memory index built from metadata. See caching an omnibox search index.

How large can an extension’s IndexedDB grow?

It shares the browser’s per-origin quota, which is large on desktop. Extensions with unlimitedStorage are exempt from normal eviction in Chrome; check navigator.storage.estimate() to see usage.

Other Testing, Debugging & Performance Optimization Resources