Choosing Between chrome.storage and IndexedDB
When an MV3 extension outgrows chrome.storage.local — query patterns, blob storage, quota and eviction differences — and how to run both stores side by side without drift.
Table of Contents
chrome.storage.local is a key-value store that serialises the whole value on every write and gives you no way to ask a question more interesting than “what is under this key”. That is exactly right for settings and exactly wrong for ten thousand cached articles. The moment you find yourself reading a 4 MB array to update one element of it, you have outgrown it. This guide is part of chrome.storage API and sync.
The shapes each store is good at
chrome.storage is extension-aware: it is available in every context including the service worker, it broadcasts changes through onChanged, and it needs no schema or version. IndexedDB is a real database: indexed queries, cursors, binary values, and transactional writes that touch one record without rewriting its neighbours.
Step-by-step: splitting the two stores cleanly
1. Draw the line at “does the UI read it on open?”
Settings, feature flags, the last selected tab, an auth token reference — small, read constantly, written rarely. Those stay in chrome.storage. Everything that is a collection goes to IndexedDB.
1// storage-map.js — one place that says where each thing lives
2export const IN_CHROME_STORAGE = ["settings", "lastSyncedAt", "uiState"];
3export const IN_INDEXEDDB = ["articles", "thumbnails", "searchIndex"];
Execution context: a shared module imported by the worker and by extension pages. Keeping the map explicit prevents the slow drift where a collection starts as a three-item array in storage.local and is 40 MB two releases later.
2. Open the database from the worker safely
A service worker can be evicted between open and your first transaction. Open lazily, per call, and never cache the connection in a module-level variable you assume is alive.
1// db.js
2const NAME = "ext-cache";
3const VERSION = 2;
4
5export function openDb() {
6 return new Promise((resolve, reject) => {
7 const req = indexedDB.open(NAME, VERSION);
8 req.onupgradeneeded = (e) => {
9 const db = req.result;
10 if (e.oldVersion < 1) {
11 const store = db.createObjectStore("articles", { keyPath: "id" });
12 store.createIndex("bySite", "site", { unique: false });
13 store.createIndex("byFetchedAt", "fetchedAt", { unique: false });
14 }
15 if (e.oldVersion < 2) {
16 req.transaction.objectStore("articles").createIndex("byUnread", "unread");
17 }
18 };
19 req.onsuccess = () => resolve(req.result);
20 req.onerror = () => reject(req.error);
21 });
22}
Execution context: the service worker or any extension page — indexedDB is a global in both. The database is scoped to the extension origin, so the worker, the popup and the options page all see the same data. Content scripts do not: they run in the page’s origin and see the page’s database.
3. Wrap the callback API once
IndexedDB’s event-based API is painful to sprinkle through business logic. One small promise wrapper is enough; a library is usually not needed.
1export async function tx(storeName, mode, fn) {
2 const db = await openDb();
3 return new Promise((resolve, reject) => {
4 const t = db.transaction(storeName, mode);
5 const result = fn(t.objectStore(storeName));
6 t.oncomplete = () => { db.close(); resolve(result); };
7 t.onerror = () => { db.close(); reject(t.error); };
8 });
9}
10
11export const putArticle = (a) => tx("articles", "readwrite", (s) => s.put(a));
Execution context: the service worker. Closing the connection on completion matters in a worker: an open connection blocks a later onupgradeneeded, and an eviction mid-transaction rolls the transaction back cleanly rather than leaving a partial write.
4. Query with an index instead of loading everything
This is the whole reason for the move — the operation that was a full read-and-filter becomes a bounded cursor.
1export function unreadForSite(site, limit = 50) {
2 return tx("articles", "readonly", (store) => new Promise((resolve) => {
3 const out = [];
4 const range = IDBKeyRange.only(site);
5 const cursorReq = store.index("bySite").openCursor(range, "prev");
6 cursorReq.onsuccess = () => {
7 const cur = cursorReq.result;
8 if (!cur || out.length >= limit) return resolve(out);
9 if (cur.value.unread) out.push(cur.value);
10 cur.continue();
11 };
12 }));
13}
Execution context: the service worker or an extension page. Cursor iteration yields to the event loop between steps, so a long scan does not block the worker — but it also means the worker must stay alive, so keep the returned promise awaited by the event handler that started it.
5. Keep a pointer, not a copy, in chrome.storage
The two stores drift the moment the same fact lives in both. Store derived summaries only, and regenerate them from IndexedDB rather than maintaining them.
1// After a sync, record only what the popup needs to render instantly.
2await chrome.storage.local.set({
3 cacheSummary: { count: await countArticles(), updatedAt: Date.now() }
4});
Execution context: the service worker. The popup reads cacheSummary synchronously on open and renders a count without touching IndexedDB, then loads the list lazily — the pattern described in loading popup data without a flash of empty UI.
Migrating a collection out of chrome.storage
The move is rarely greenfield. It usually starts with a key that has quietly grown — articles holding a 6 MB array — and it has to happen on installs that are already running, without losing anything and without a long blocking step on startup.
Do it as a one-way migration driven by a version marker, and make it resumable for the same reason any long job must be.
1const SCHEMA = 3; // bumped when the collection moved
2
3async function migrateIfNeeded() {
4 const { schema = 1, migrateCursor = 0 } = await chrome.storage.local.get(["schema", "migrateCursor"]);
5 if (schema >= SCHEMA) return;
6
7 const { articles = [] } = await chrome.storage.local.get("articles");
8 const slice = articles.slice(migrateCursor, migrateCursor + 200);
9 for (const a of slice) await putArticle(a); // into IndexedDB
10
11 const next = migrateCursor + slice.length;
12 if (next < articles.length) {
13 await chrome.storage.local.set({ migrateCursor: next });
14 chrome.alarms.create("migrate", { delayInMinutes: 1 });
15 return;
16 }
17 await chrome.storage.local.remove(["articles", "migrateCursor"]);
18 await chrome.storage.local.set({ schema: SCHEMA });
19}
Execution context: the service worker, called from onInstalled and from the migrate alarm. The source array is only deleted after the last slice lands, so an eviction anywhere in the middle costs a repeat of at most 200 records — the chaining pattern from chaining alarms for long-running jobs applied to a migration.
Two details save real grief. First, read the source array once per slice rather than holding it across ticks: the worker may be evicted between them, and a stale copy would silently re-migrate deleted records. Second, keep the reader tolerant of both shapes while the migration is in flight — for the minute or two it runs, some records are in IndexedDB and some are not.
1export async function getArticle(id) {
2 const fromDb = await tx("articles", "readonly", (s) => s.get(id));
3 if (fromDb) return fromDb;
4 const { articles = [] } = await chrome.storage.local.get("articles");
5 return articles.find((a) => a.id === id) ?? null; // fallback during migration
6}
Execution context: the service worker or an extension page. The fallback costs one extra read on a miss and disappears with the next release, once the schema marker guarantees every install has migrated.
Cross-browser variation
- Chrome / Edge: IndexedDB in an extension origin is not evicted under normal storage pressure once the extension declares
"unlimitedStorage"; without it, both stores share the default quota and can be evicted together. - Firefox: IndexedDB is fully available to MV3 background scripts. Firefox’s private-browsing mode uses an in-memory IndexedDB that is discarded on exit — relevant if your extension is allowed to run in private windows.
- Safari: IndexedDB in a Safari web extension works but has historically been the flakiest of the three, particularly around
onupgradeneededfiring after a suspended background context resumes. Keep the schema upgrade path short and idempotent, and see handling Safari Web Extension conversion gaps. - All three:
storage.onChangednever fires for IndexedDB writes. If another context needs to know a record changed, send a message or bump a counter inchrome.storage.
Verification
- Confirm both stores are visible from the worker. In the service worker console:
1[await chrome.storage.local.getBytesInUse(null), (await navigator.storage.estimate()).usage];
2// [ 41233, 18446221 ] ← settings tiny, cache large
Execution context: the service worker console. navigator.storage.estimate() reports the origin’s total usage including IndexedDB, so a large second number with a small first one means the split is working.
- Open DevTools on any extension page, go to Application → IndexedDB, and confirm your object store and indexes exist with the expected version number.
- Delete a single record and confirm
chrome.storage.localdid not change size — proof that the collection is no longer duplicated into a key.
FAQ
Can a content script read the extension’s IndexedDB?
No. A content script executes in the page’s origin, so indexedDB there is the page’s database. Route the request through the service worker with message passing and let the worker query on the extension’s behalf.
Do I still need unlimitedStorage?
Declare it if your cache can exceed a few megabytes. It raises the quota for both chrome.storage.local and IndexedDB, and it is a permission reviewers accept readily when the extension visibly caches content — see writing a permission justification that passes.
Is IndexedDB slower than chrome.storage for small reads?
For a single small key, yes — opening a connection and a transaction costs more than a storage.local.get. That asymmetry is the argument for keeping the hot, tiny values in chrome.storage rather than moving everything.
Related
- Batching storage writes to stay under quota — the write discipline that delays the need for a second store.
- Handling storage quota exceeded errors — what the limits look like when you hit them.
- Local vs sync storage performance comparison — measured costs of the storage areas.
- chrome.storage API and sync — the parent guide to extension storage.