Searching and Pruning Browsing History
Query chrome.history at scale from an MV3 worker — time-windowed pagination, visit counts vs visit records, deleting ranges, and keeping the whole thing off the network.
Table of Contents
chrome.history.search({ text: "", startTime: 0, maxResults: 0 }) is a single line that asks the browser to clone a hundred thousand records into your service worker. It will usually work on your machine and fail on a heavy user’s, and there is no cursor to fall back on. Querying history well means windowing by time and accepting that you will make many small calls instead of one big one. This guide is part of bookmarks, history and downloads APIs.
Two different record types
search returns HistoryItems: one per URL, with a lastVisitTime and a visitCount. getVisits returns VisitItems: one per actual visit, with a transition type and a referring visit id. Most “how often” questions are answered by the first; only sequence questions need the second.
Step-by-step
1. Window by time and always set maxResults
1const DAY = 864e5;
2
3async function historyWindow(endTime, spanDays = 1, maxResults = 1000) {
4 return chrome.history.search({
5 text: "",
6 startTime: endTime - spanDays * DAY,
7 endTime,
8 maxResults,
9 });
10}
Execution context: the service worker or an extension page. maxResults: 0 is “unlimited”, not “none” — the default is 100, and leaving it out is safer than setting it to zero.
2. Page backwards a window at a time
There is no cursor, so pagination means moving the window. Stop when a window comes back empty or you have enough.
1async function* historyPages(sinceMs) {
2 let end = Date.now();
3 while (end > sinceMs) {
4 const page = await historyWindow(end, 1, 1000);
5 if (!page.length) { end -= DAY; continue; }
6 yield page;
7 end = Math.min(...page.map((i) => i.lastVisitTime ?? end)) - 1;
8 }
9}
Execution context: the service worker. Each iteration is an await on a chrome.* call, which keeps the worker alive — but a sweep of a year of history can exceed the five-minute ceiling, so drive it from an alarm chain rather than a single event, as in chaining alarms for long-running jobs.
3. Aggregate as you go, never accumulate
The point of windowing is defeated if you push every page into one array.
1async function domainTotals(sinceMs) {
2 const totals = new Map();
3 for await (const page of historyPages(sinceMs)) {
4 for (const item of page) {
5 if (!item.url) continue;
6 const host = new URL(item.url).hostname;
7 totals.set(host, (totals.get(host) ?? 0) + (item.visitCount ?? 1));
8 }
9 }
10 return [...totals].sort((a, b) => b[1] - a[1]).slice(0, 50);
11}
Execution context: the service worker. The Map holds one entry per host — thousands, not hundreds of thousands — which is small enough to survive in worker memory for the duration of a single alarm tick and to persist to storage at the end.
4. Use getVisits only for the questions that need it
1const TRANSITIONS_OF_INTEREST = new Set(["typed", "auto_bookmark", "generated"]);
2
3async function wasDeliberate(url) {
4 const visits = await chrome.history.getVisits({ url });
5 return visits.some((v) => TRANSITIONS_OF_INTEREST.has(v.transition));
6}
Execution context: the service worker. transition distinguishes a page the user typed from one they were redirected to, which is the difference between “they use this site” and “an ad sent them there”.
5. Delete precisely
deleteRange removes every visit in a window across all URLs; deleteUrl removes one URL entirely. Neither can be undone.
1// Remove one site's records without touching anything else.
2await chrome.history.deleteUrl({ url: "https://example.com/private" });
3
4// Remove the last hour, for a "clear recent" feature.
5await chrome.history.deleteRange({ startTime: Date.now() - 3600e3, endTime: Date.now() });
Execution context: the service worker. Both fire onVisitRemoved with allHistory: false and the affected URLs; a full deleteAll fires it with allHistory: true and no URL list, which is the case listeners most often forget to handle.
6. Keep it local
History is the most sensitive data an extension can hold. Derive locally, transmit nothing raw.
1// Ship a shape, not the records.
2const report = {
3 window: "30d",
4 topCategories: categorise(await domainTotals(Date.now() - 30 * DAY)),
5 totalVisits: undefined, // deliberately not sent
6};
Execution context: the service worker. Any network call made by an extension holding the history permission draws review attention; a listing that says “your history never leaves your device” and a build that honours it is the shortest path through — see reporting errors without breaking your privacy policy.
Transition types, and what they let you infer
VisitItem.transition is the field that turns a list of URLs into something you can reason about. It records how the user arrived, and the categories are more useful than they first look.
typed— the user typed the URL or picked it from the omnibox. The strongest signal of intent available.link— a click from another page. Common and weak on its own; meaningful in a chain.auto_bookmark— opened from a bookmark or the bookmarks bar.reload— a refresh, which inflatesvisitCounton pages people leave open.form_submit,generated,keyword— search-adjacent arrivals.auto_subframe/manual_subframe— an iframe navigated. These are the ones that quietly double your totals if you do not filter them.
That last pair matters more than any other for accuracy. A page with three embedded widgets can contribute four history rows, and an extension that counts “pages read” without filtering sub-frames will report numbers the user knows are wrong.
1const REAL_VISIT = new Set(["typed", "link", "auto_bookmark", "form_submit",
2 "generated", "keyword", "reload"]);
3
4async function meaningfulVisits(url) {
5 const visits = await chrome.history.getVisits({ url });
6 return visits.filter((v) => REAL_VISIT.has(v.transition));
7}
Execution context: the service worker. Note reload is included here: a reload is a real visit for “time spent” purposes and a duplicate for “pages discovered” purposes, so which set is right depends on the question — decide it deliberately rather than by omission.
The referrer chain is the other half. referringVisitId links a visit to the one that produced it, so a run of link transitions can be walked backwards to the typed visit that started the session. That is how you turn “they visited forty pages” into “they started three research sessions”, which is a statement worth showing a user.
Cross-browser variation
- Chrome / Edge:
search,getVisits,addUrl,deleteUrl,deleteRangeanddeleteAllare all available.visitCountandtypedCountare populated;transitionvalues follow the Chrome enumeration. - Firefox: implements the same surface under
browser.history, with a slightly different transition vocabulary and notypedCounton some versions. Firefox’ssearchhonoursmaxResultsidentically. - Safari: history access is read-oriented. Deletion APIs are limited or absent depending on version, so treat any pruning feature as Chrome-and-Firefox only and hide it elsewhere.
- All three: the
historypermission warning is one of the most alarming a user sees. Requesting it optionally, at the moment a history-backed feature is enabled, materially improves acceptance.
Verification
- Confirm your windowing returns bounded pages:
1(await historyWindow(Date.now(), 1, 1000)).length;
2// 342 ← a day's URLs, not a profile's
Execution context: the service worker console. A number equal to your maxResults means the window is saturated and you are silently dropping rows — narrow the span.
- Run the full aggregate over thirty days and confirm the worker is not evicted mid-run; watch
chrome://extensionsfor the worker going inactive. - Call
deleteUrlfor a test URL and confirmonVisitRemovedfires with that URL andallHistory: false. - Confirm with DevTools Network that no request leaves the extension while the report is generated.
FAQ
Is there really no cursor?
No. Time-windowed re-queries are the supported pagination. If you need cursor semantics, mirror the aggregate you care about into IndexedDB once and query that afterwards — the trade-off in choosing between chrome.storage and IndexedDB.
Does search include visits from other profiles or incognito?
No. It covers the current profile’s non-incognito history only. Incognito visits are never recorded, so there is nothing to query.
Why is visitCount higher than the number of getVisits rows?
Because the visit table is pruned more aggressively than the URL table. Treat visitCount as the authoritative total and getVisits as the recent detail.
Related
- Reading and writing bookmarks safely — the smaller, sturdier sibling store.
- Justifying sensitive data permissions — clearing review with the history permission.
- Managing downloads from an extension — exporting a report the user asked for.
- Bookmarks, history and downloads APIs — the parent guide.