Caching an Omnibox Search Index
Keep omnibox suggestions instant in an MV3 extension: building a compact search index, caching it in memory and storage.session across worker restarts, IndexedDB for large data, incremental updates, and warming on keyword entry.
Table of Contents
The first suggestion after typing the keyword takes almost a second to appear, then the next keystrokes are instant — until the user pauses for a minute, the service worker is stopped, and the next keystroke is slow again. Each cold start reloads thousands of saved items from storage, lower-cases and tokenises them, and only then answers the query. The omnibox is the fastest UI an extension has, and a slow first suggestion undermines it. This guide builds a compact index, caches it across worker restarts, keeps it current with incremental updates, and warms it before the user finishes typing. It belongs to omnibox and address bar integration.
Where the time goes on a cold start
An omnibox keystroke that arrives at a stopped worker pays for: starting the worker, running its top-level code, loading the data (from storage.local, IndexedDB or the network), building whatever structures the search needs, and finally ranking. The first three are mostly fixed. Loading and building scale with data size and dominate for libraries of thousands of items. The fix is to persist the built index in a form that loads quickly — storage.session (in-memory, survives worker restarts within a browser session) for small-to-medium indexes, IndexedDB for large ones — and to update it incrementally when items change instead of rebuilding.
Step-by-step: an index that is always ready
1. Index only what search needs
1// index-build.js
2export function buildIndex(items) {
3 return {
4 v: 2,
5 builtAt: Date.now(),
6 rows: items.map((it) => [it.id, it.title.toLowerCase(), hostOf(it.url), it.lastUsed ?? 0, it.useCount ?? 0]),
7 };
8}
Execution context: the service worker. The index stores compact rows — id, lowercased title, host, usage numbers — not full items with notes and content. Arrays are smaller than objects when serialised. Full details are loaded only for the handful of results actually shown. A version number lets you discard incompatible cached indexes after an update.
2. Keep the index in memory, backed by storage.session
1// omnibox-index.js
2let index = null;
3let loading = null;
4
5export function getIndex() {
6 if (index) return Promise.resolve(index);
7 return (loading ??= (async () => {
8 const { omniIndex } = await chrome.storage.session.get("omniIndex");
9 if (omniIndex?.v === 2) return (index = omniIndex);
10 index = buildIndex(await loadAllItems());
11 chrome.storage.session.set({ omniIndex: index });
12 return index;
13 })().finally(() => { loading = null; }));
14}
Execution context: the service worker. The module variable serves warm requests instantly; on a cold start, storage.session returns the prebuilt index without rebuilding. Sharing one in-flight promise means a burst of keystrokes during loading triggers only one load. storage.session is cleared when the browser restarts, so the first keyword use per browser session builds once. Its quota (about 10 MB) suits indexes of tens of thousands of rows.
3. Update incrementally when items change
1chrome.storage.onChanged.addListener(async (changes, area) => {
2 if (area !== "local") return;
3 const touched = Object.keys(changes).filter((k) => k.startsWith("item:"));
4 if (!touched.length) return;
5 const idx = await getIndex();
6 for (const key of touched) {
7 const id = key.slice(5);
8 const i = idx.rows.findIndex((r) => r[0] === id);
9 const next = changes[key].newValue;
10 if (!next) { if (i >= 0) idx.rows.splice(i, 1); continue; }
11 const row = [id, next.title.toLowerCase(), hostOf(next.url), next.lastUsed ?? 0, next.useCount ?? 0];
12 if (i >= 0) idx.rows[i] = row; else idx.rows.push(row);
13 }
14 scheduleSessionSave(idx);
15});
Execution context: the service worker. Applying item changes to the existing index avoids a full rebuild after every save. Debounce writing the index back to storage.session (scheduleSessionSave) so a bulk import does not write it hundreds of times. For indexes keyed by id, keep a Map from id to row position to avoid the linear findIndex.
4. Warm the index when the keyword is entered
1chrome.omnibox.onInputStarted.addListener(() => { getIndex(); });
Execution context: the service worker. onInputStarted fires when the user enters keyword mode, before they type the query. Starting the load then hides most of the cold-start time behind the user’s typing. It is the cheapest optimisation in this guide.
5. Use IndexedDB for very large data
1// For 100k+ items: store rows bucketed by the first character of each word
2const db = await openDB("omni", 1, { upgrade: (d) => d.createObjectStore("buckets") });
3async function candidates(q) {
4 const bucket = await db.get("buckets", q[0]); // ids whose words start with q[0]
5 return bucket ?? [];
6}
Execution context: the service worker with a small IndexedDB wrapper such as idb. When the full index is too big to load on every cold start, store it bucketed by first letter and load only the bucket the query needs. Score within that bucket. See keeping IndexedDB fast in an extension.
6. Fetch details only for shown results
1const top = rank(idx, q).slice(0, 6);
2const details = await chrome.storage.local.get(top.map((r) => `item:${r[0]}`));
Execution context: the service worker. The index has enough to rank; descriptions need titles with original case and URLs, which a batched get of six keys returns quickly. Never load all item details to answer a query.
7. Measure, don’t guess
Log performance.now() around getIndex() and ranking in development, and test with a realistic library size after stopping the worker in chrome://serviceworker-internals. Optimise the step that dominates — usually loading, sometimes ranking.
Common mistakes
- Rebuilding on every cold start. Persist the built index.
- Storing full items in the index. Bloats load time.
- Rebuilding after each change. Update incrementally.
- Waiting for the first character. Warm on
onInputStarted. - No version on the cached index. Stale formats after updates.
Cross-browser variation
- Chrome / Edge:
storage.session(Chrome 102+) andonInputStartedavailable. - Firefox:
storage.sessionis supported in recent versions; the background may be an event page that stays alive longer, making in-memory caching more effective. - Safari: no omnibox API; the same index can power search in the popup.
Verification
- Stop the worker, type the keyword and a query, and measure time to first suggestion.
- Save a new item and confirm it appears in suggestions without a full rebuild.
- Restart the browser and confirm the index rebuilds once and is then cached.
- Bump the index version and confirm the old cached index is discarded.
FAQ
Is storage.session shared with content scripts?
Not by default; its access level is trusted contexts only, which is what you want for an index.
How big can the index be in storage.session?
About 10 MB in Chrome. Beyond that, use IndexedDB.
Should the index be built in an offscreen document?
Only if building takes long enough to matter; for most libraries the worker handles it in milliseconds once data is loaded.
Can the popup use the same index?
Yes. Request it from the service worker with a message, or read storage.session directly from the popup, which is a trusted context. Sharing one index keeps omnibox and popup search results consistent.
Should I precompute ranking scores?
Precompute the parts that do not depend on the query — usage boosts, normalised titles — and recompute them occasionally. Only match scoring needs to run per keystroke.
What happens if the index and the data disagree?
Treat the index as a cache: when a ranked id has no matching item in storage, drop it from the results and from the index. Rebuilding fully once a day with an alarm also corrects any drift from missed change events.
Related
- Ranking omnibox suggestions — what runs on the index.
- Providing omnibox suggestions asynchronously — async suggest.
- Rebuilding in-memory state after termination — the restart problem.
- Omnibox and address bar integration — the parent topic.