Providing Omnibox Suggestions Asynchronously

Serve omnibox suggestions fast enough to keep pace with typing — onInputChanged from a cold worker, discarding stale results, escaping the XML description markup, and ranking a small index.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

onInputChanged fires on every keystroke, from a service worker that may have been asleep a moment ago, and the user is still typing while you compute. Results that arrive late are shown against the wrong query; results computed for “al” can land after the user has typed “alarms” and replace better ones. And one unescaped ampersand in a description makes the browser drop the whole suggestion list without an error. This guide is part of omnibox and address bar integration.

The race on every keystroke

Three keystrokes, three queries, one visible answerQueries for 'a', 'al' and 'ala' overlap; the slow query for 'al' finishes after the fast one for 'ala' and must be discarded.keystroke 1+200 msquery "…skipped:…query "al"slow, 90 msquery "ala"fast, 20 msidleuser reading"ala" results shown"al" results arrive — discard
Without a staleness check the dropdown briefly shows results for text the user has already typed past.

Step-by-step

1. Register at the top level and answer every call

1chrome.omnibox.onInputChanged.addListener((text, suggest) => {
2  handleInput(text, suggest);             // do not await here; keep the listener synchronous
3});

Execution context: the service worker. The first keystroke after activation may wake the worker, and it is delivered at the end of the first synchronous pass — so the listener must exist by then, as set out in registering listeners at the top level.

2. Drop stale results with a sequence number

 1let seq = 0;
 2
 3async function handleInput(text, suggest) {
 4  const mine = ++seq;
 5  const q = text.trim().toLowerCase();
 6  if (q.length < 2) return suggest([]);
 7
 8  const results = await search(q);
 9  if (mine !== seq) return;               // a newer keystroke has superseded this one
10  suggest(results.slice(0, 6).map(toSuggestion));
11}

Execution context: the service worker. Each call captures its sequence number; any result whose number is no longer current is thrown away. The counter lives in worker memory — if the worker is evicted mid-session, it restarts at zero, which is harmless because all earlier calls died with it.

3. Escape descriptions for the omnibox markup

Suggestion descriptions are parsed as a small XML dialect. Only <match>, <dim> and <url> are recognised, and the text between them must be XML-escaped.

 1function escapeXml(s) {
 2  return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
 3}
 4
 5function toSuggestion(hit) {
 6  return {
 7    content: hit.url,
 8    description: `<match>${escapeXml(hit.title)}</match> <dim>— ${escapeXml(hit.section)}</dim> <url>${escapeXml(hit.url)}</url>`,
 9    deletable: false,
10  };
11}

Execution context: the service worker. A title like “Tabs & windows” without escaping produces invalid XML, and Chrome discards the entire suggestion list with nothing in the console. content is what Enter passes to onInputEntered when this suggestion is chosen — making it the URL keeps the Enter handler simple.

4. Search an in-memory index, not the network

At typing speed the budget per query is a few tens of milliseconds, including a possible cold start. That rules out a network round trip on every keystroke.

 1let index = null;
 2
 3async function getIndex() {
 4  if (index) return index;
 5  const { docIndex = [] } = await chrome.storage.local.get("docIndex");
 6  index = docIndex.map((d) => ({ ...d, key: `${d.title} ${d.aliases?.join(" ") ?? ""}`.toLowerCase() }));
 7  return index;
 8}
 9
10async function search(q) {
11  const idx = await getIndex();
12  const terms = q.split(/\s+/);
13  return idx
14    .map((d) => ({ d, score: rank(d, terms) }))
15    .filter((x) => x.score > 0)
16    .sort((a, b) => b.score - a.score)
17    .map((x) => x.d);
18}

Execution context: the service worker. The index is loaded from storage once per worker lifetime and held in memory for the rest of the session. A few thousand entries search in well under a millisecond; refresh the stored index on a schedule rather than on the typing path, as in scheduling daily and weekly syncs.

5. Rank for what people actually type

 1function rank(doc, terms) {
 2  let score = 0;
 3  for (const t of terms) {
 4    if (doc.key.startsWith(t)) score += 10;          // prefix of the title
 5    else if (doc.key.includes(` ${t}`)) score += 6;  // start of a later word
 6    else if (doc.key.includes(t)) score += 2;        // anywhere
 7    else return 0;                                    // every term must match
 8  }
 9  return score + (doc.popularity ?? 0) / 100;
10}

Execution context: the service worker. Prefix matches dominate because omnibox users type the beginning of what they want. A small popularity term breaks ties in favour of pages people open most — which can be learned locally from onInputEntered without any data leaving the device.

6. Fall back to the network only when idle

If a remote search is genuinely needed — a huge corpus, fresh results — debounce it and use it only when the local index has nothing.

 1let remoteTimer;
 2async function handleInput(text, suggest) {
 3  const mine = ++seq;
 4  const local = await search(text.toLowerCase());
 5  if (mine !== seq) return;
 6  suggest(local.slice(0, 6).map(toSuggestion));
 7
 8  clearTimeout(remoteTimer);
 9  if (local.length < 3) {
10    remoteTimer = setTimeout(async () => {
11      const remote = await fetchRemote(text).catch(() => []);
12      if (mine === seq) suggest([...local, ...remote].slice(0, 6).map(toSuggestion));
13    }, 250);
14  }
15}

Execution context: the service worker. Calling suggest a second time replaces the list, so the user sees local results immediately and remote ones fill in if they pause. The setTimeout is acceptable here because it lives inside an active omnibox session, which keeps the worker alive.

One keystroke through the suggestion pathonInputChanged assigns a sequence number, queries the warm in-memory index, checks the result is still current, escapes descriptions and calls suggest.Address baronInputChangedIn-memory indexsuggest()text: "alarms"seq = 7search("alarms")ranked hitsseq still 7? yesescaped descriptionsdropdown updates
The staleness check sits between the query and suggest — the only place it can prevent a flicker.
Suggestion latency by data sourceTime from keystroke to suggestions shown for an in-memory index, a storage read per keystroke, IndexedDB per keystroke and a network fetch per keystroke.In-memory index (warm)2 msstorage.local read per keystroke9 msIndexedDB query per keystroke14 msNetwork fetch per keystroke180 ms
Anything above roughly 50 ms falls behind a fast typist — keep the network off the per-keystroke path.

Keeping the index fresh without slowing typing

An in-memory index is fast precisely because it is not updated while the user types. It still has to change — new pages appear, titles are renamed — and the refresh must happen somewhere that is not the typing path.

The pattern that works is to rebuild the stored index on a schedule and swap the in-memory copy when storage changes:

1chrome.storage.onChanged.addListener((changes, area) => {
2  if (area === "local" && changes.docIndex) index = null;   // next query reloads it
3});

Execution context: the service worker. Clearing the cached copy rather than rebuilding immediately means the rebuild cost is paid by the next query, at most once, instead of by a background event that may fire in the middle of a typing session. The job that produces docIndex runs from an alarm, fetches the source list, normalises titles and aliases, and writes the result in a single set call.

Two details keep the index useful as well as fast. Store aliases alongside titles — “sw” for “service worker”, “dnr” for “declarativeNetRequest” — because people type the abbreviation they use, not the title you chose. And store a small popularity number that increments locally in onInputEntered, so the pages each user actually opens float to the top of their own suggestions over time. Both are cheap, both live entirely on the device, and together they account for most of the difference between a suggestion list that feels clever and one that feels literal.

Cross-browser variation

  • Chrome / Edge: descriptions support <match>, <dim> and <url> markup and must be valid XML. Calling suggest again replaces the list. Up to about six suggestions are shown.
  • Firefox: browser.omnibox.onInputChanged with the same callback, but descriptions are plain text — markup tags are shown literally. Build descriptions per engine, or strip the tags for Firefox.
  • Safari: no omnibox. The same index and ranking can power a search box in the popup instead.
  • All three: where the omnibox exists, the listener is on the typing path. A cold worker adds its start-up time to the first keystroke only; keep the module graph small, as covered in reducing service worker cold start latency.

Verification

  1. Type quickly and confirm the dropdown never shows results for an earlier prefix.
  2. Add an index entry titled “Tabs & windows” and confirm it appears — if the whole list vanishes, escaping is missing.
  3. Measure the path from the worker console:
1const t0 = performance.now();
2await search("alarms");
3performance.now() - t0;
4// 0.4

Execution context: the service worker console with the index warm. Anything above a few milliseconds means the index is being rebuilt or re-read on each call.

  1. Stop the worker from chrome://extensions, then type the keyword and a query — the first suggestions should still appear promptly.

FAQ

How many suggestions should I return?

Five or six. The browser shows a limited number, and more only slows the work without being seen.

Can a suggestion open something other than a URL?

content can be any string; onInputEntered receives it and decides what to do. Using a URL for navigational results and a prefixed token for actions (action:new-note) keeps the Enter handler readable.

Why does Firefox show <match> tags literally?

Firefox treats descriptions as plain text. Strip the markup in the Firefox build, or build the description without tags when browser.omnibox is detected.

Other UI/UX Patterns & Interactive Components Resources