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.
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.
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.
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.
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
fetchinside 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;
durabilityhint supported. - Firefox: fully supported in background scripts and pages;
durabilityhint 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.localtoo.
Verification
- Import 5,000 items and confirm it completes in about a second or two on a typical machine.
- Time the popup’s recent-items query with 20,000 items and confirm it stays in the low milliseconds.
- Confirm list views never read the
contentstore. - 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.
Related
- Measuring storage read and write latency — measurement.
- Migrating IndexedDB schemas on update — upgrades.
- Storing large blobs and files in an extension — binary data.
- Performance profiling and optimisation — the parent topic.