Ranking Omnibox Suggestions
Rank chrome.omnibox suggestions so the best match is first: scoring prefix, word and fuzzy matches, boosting by recency and frequency, the default suggestion, limiting results, and keeping ranking fast as the user types.
Table of Contents
The user types the extension’s keyword, a space, and “react” — and the first omnibox suggestion is “Preact signals cheat sheet”, saved two years ago, while “React hooks reference”, opened yesterday, is fifth. chrome.omnibox shows suggestions in exactly the order you pass them to suggest(); it does no ranking of its own. With only five or so visible rows and users who press Enter on the first plausible result, ranking decides whether the omnibox integration feels magical or useless. This guide builds a scorer that combines match quality with recency and frequency, handles the default suggestion, and stays fast enough to run on every keystroke. It belongs to omnibox and address bar integration.
How the omnibox shows extension suggestions
After the user types the extension’s keyword and a space (or Tab), Chrome enters keyword mode and sends each change of input to omnibox.onInputChanged(text, suggest). You call suggest(results) with an array of { content, description, deletable }. Chrome displays them in the order given, below a default suggestion — the first row — which you set separately with omnibox.setDefaultSuggestion({ description }). The number of visible rows is limited by the browser (typically around five to nine including the default). When the user presses Enter on the default row, onInputEntered receives the raw typed text; on any other row, it receives that suggestion’s content. So ranking means two things: the order of the array, and what the default row says it will do with the typed text.
Step-by-step: a ranking that feels right
1. Normalise candidates once
1// index.js — built when data loads, not per keystroke
2export function buildIndex(items) {
3 return items.map((it) => ({
4 ...it,
5 titleLc: it.title.toLowerCase(),
6 words: it.title.toLowerCase().split(/[\s\-_/.:]+/).filter(Boolean),
7 urlLc: it.url.toLowerCase(),
8 }));
9}
Execution context: the service worker. Lower-casing and tokenising every item on every keystroke wastes time; precompute once and cache, rebuilding when the data changes. See caching an omnibox search index for keeping this across worker restarts.
2. Score match quality in tiers
1export function matchScore(item, q) {
2 if (!q) return 0;
3 if (item.titleLc === q) return 1000; // exact title
4 if (item.titleLc.startsWith(q)) return 800; // title prefix
5 if (item.words.some((w) => w.startsWith(q))) return 600; // word prefix: "hooks" in "React hooks…"
6 if (item.titleLc.includes(q)) return 400; // substring
7 if (item.urlLc.includes(q)) return 250; // URL match
8 return fuzzy(item.titleLc, q) ? 100 : 0; // subsequence: "rhr" → "React Hooks Reference"
9}
10
11function fuzzy(hay, q) {
12 let i = 0;
13 for (const ch of hay) if (ch === q[i] && ++i === q.length) return true;
14 return false;
15}
Execution context: the service worker. Tiers keep ranking explainable: any title-prefix match beats any substring match, regardless of usage. Within a tier, usage decides. Multi-word queries can score each word and take the minimum, so all words must match.
3. Boost by recency and frequency
1const HALF_LIFE_DAYS = 14;
2
3export function usageBoost(item, now = Date.now()) {
4 const ageDays = (now - (item.lastUsed ?? item.savedAt)) / 864e5;
5 const recency = Math.pow(0.5, ageDays / HALF_LIFE_DAYS); // 1.0 today, 0.5 after two weeks
6 const frequency = Math.log2(1 + (item.useCount ?? 0)); // diminishing returns
7 return Math.round(150 * recency + 40 * frequency); // max ~150 + small frequency term
8}
9
10export const score = (item, q) => {
11 const m = matchScore(item, q);
12 return m ? m + usageBoost(item) : 0;
13};
Execution context: the service worker. Exponential decay with a half-life makes yesterday’s item beat last year’s within the same tier; a logarithmic frequency term rewards habit without letting one item dominate forever. Keep the maximum boost below the gap between tiers (here, 200) so usage reorders within a tier but never promotes a fuzzy match above a prefix match.
4. Record usage when a suggestion is chosen
1chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
2 const item = byUrl.get(text) ?? (await bestMatch(text));
3 if (!item) return openSearch(text, disposition);
4 await recordUse(item.id); // lastUsed = now, useCount++
5 openUrl(item.url, disposition);
6});
Execution context: the service worker. Updating lastUsed and useCount when the user picks an item is what makes ranking learn. Store usage separately from the items (for example a usage map in storage.local) so syncing items does not overwrite local habits. See handling omnibox input entered navigation for honouring disposition.
5. Sort, slice and set the default
1chrome.omnibox.onInputChanged.addListener((text, suggest) => {
2 const q = text.trim().toLowerCase();
3 const ranked = index
4 .map((it) => ({ it, s: score(it, q) }))
5 .filter((x) => x.s > 0)
6 .sort((a, b) => b.s - a.s)
7 .slice(0, 6);
8
9 if (ranked.length) {
10 const top = ranked[0].it;
11 chrome.omnibox.setDefaultSuggestion({ description: `Open: ${escapeXml(top.title)}` });
12 byDefault = top;
13 suggest(ranked.slice(1).map(({ it }) => ({ content: it.url, description: `${escapeXml(it.title)} <url>${escapeXml(it.url)}</url>` })));
14 } else {
15 chrome.omnibox.setDefaultSuggestion({ description: `Search saved pages for <match>${escapeXml(text)}</match>` });
16 byDefault = null;
17 suggest([]);
18 }
19});
Execution context: the service worker. The default row describes what Enter will do — open the top match — and the onInputEntered handler uses byDefault when it receives the raw typed text, so the description and the behaviour agree. The remaining matches go into suggest(). Descriptions are XML; escape every value, as covered in escaping XML in omnibox descriptions.
6. Keep it fast
1let pending;
2chrome.omnibox.onInputChanged.addListener((text, suggest) => {
3 clearTimeout(pending);
4 pending = setTimeout(() => rankAndSuggest(text, suggest), index.length > 5000 ? 60 : 0);
5});
Execution context: the service worker. Scoring a few thousand items is instant; tens of thousands may take tens of milliseconds per keystroke. A short debounce for large indexes avoids wasted work while the user is still typing. For very large data, prefilter with a prefix map of first letters or use a library such as FlexSearch.
7. Diversify when results look identical
If several top results share a domain or nearly identical titles, interleave them with other matches or collapse duplicates (keep the most recently used). Five rows of the same site is rarely what the user wanted.
8. Test ranking with fixtures
1test("recent title-prefix beats old title-prefix", () => {
2 const now = Date.parse("2026-10-02");
3 const a = { titleLc: "react hooks", words: ["react","hooks"], urlLc: "", lastUsed: now - 864e5 };
4 const b = { titleLc: "react router", words: ["react","router"], urlLc: "", lastUsed: now - 400 * 864e5 };
5 expect(score(a, "react")).toBeGreaterThan(score(b, "react"));
6});
Execution context: a unit test. Ranking regressions are subtle; a handful of fixture assertions for the cases users care about catches them. See unit testing extension logic.
Common mistakes
- Passing results unsorted. Chrome shows them as given.
- Usage boosts larger than tier gaps. Fuzzy matches outrank exact ones.
- Default suggestion that lies. Its description must match what Enter does.
- Re-tokenising per keystroke. Precompute the index.
- Never recording choices. Ranking cannot learn.
Cross-browser variation
- Chrome / Edge: ranking as described; Chrome shows a limited number of rows.
- Firefox:
browser.omniboxsupports the same events; descriptions are plain text (no XML markup), so formatting tags are ignored or must be stripped. - Safari: no omnibox API; offer search in the popup instead. See omnibox support and alternatives across browsers.
Verification
- Type a query that matches items in several tiers and confirm tier order.
- Pick a lower result, repeat the query the next day, and confirm it moved up within its tier.
- Confirm the default suggestion’s text matches what Enter opens.
- Profile ranking with 10,000 items and confirm each keystroke completes quickly.
FAQ
Can I control how many rows Chrome shows?
No. Pass a handful of the best results; extras are not shown.
Should the default suggestion be the top match or a search?
If a strong match exists, open it; otherwise offer a search. Make the description say which.
Does Chrome merge my suggestions with history?
No. In keyword mode only your suggestions appear.
How do I rank multi-word queries?
Score each word separately and combine — for example, take the lowest tier among the words and add the usage boost once — so every word must match and the weakest match decides the tier.
Related
- Providing omnibox suggestions asynchronously — async data sources.
- Caching an omnibox search index — fast data.
- Escaping XML in omnibox descriptions — safe descriptions.
- Omnibox and address bar integration — the parent topic.