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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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.

HistoryItem against VisitItemComparison of what search() and getVisits() return, their cost, and the questions each can answer.Propertyhistory.search()history.getVisits()GranularityOne row per URLOne row per visitCostOne call, boundedOne call per URLHas transition typeNoYes — link, typed, reload…Has referrer chainNoYes, via referringVisitIdAnswers "how often"visitCountBy countingAnswers "in what order"NoYes
Reach for getVisits only when the order of visits matters — it is one call per URL.

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.

A month of history, processed in day-sized windowsEach alarm tick queries one day of history, folds the results into a running aggregate in storage, and schedules the next window until the range is covered.Alarm tickcold workerRead cursorendTime from storagesearch(1 day)maxResults 1000fold, persist, advance the windowFold into totalsper hoststorage.local.setaggregate + cursorSchedule next tickor finish
Peak memory is one day of rows, and the worker is awake for a couple of seconds per tick.

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 inflates visitCount on 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.

Visit rows by transition type on a typical browsing dayDistribution of transition types across one day of history, showing how large a share sub-frame navigations occupy.link212 rowsauto_subframe148 rowsfilter these outreload64 rowstyped41 rowsstrongest intent signalform_submit22 rowsauto_bookmark9 rows
Sub-frame rows are almost a third of the raw total and almost never belong in a user-facing count.

Cross-browser variation

  • Chrome / Edge: search, getVisits, addUrl, deleteUrl, deleteRange and deleteAll are all available. visitCount and typedCount are populated; transition values follow the Chrome enumeration.
  • Firefox: implements the same surface under browser.history, with a slightly different transition vocabulary and no typedCount on some versions. Firefox’s search honours maxResults identically.
  • 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 history permission 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

  1. 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.

  1. Run the full aggregate over thirty days and confirm the worker is not evicted mid-run; watch chrome://extensions for the worker going inactive.
  2. Call deleteUrl for a test URL and confirm onVisitRemoved fires with that URL and allHistory: false.
  3. 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.

Other Core APIs & Cross-Browser Data Management Resources