Bookmarks, History & Downloads APIs
Read and write the user's bookmarks, browsing history and downloads from a Manifest V3 extension — permissions, event models, quotas and the review scrutiny these APIs attract.
chrome.bookmarks, chrome.history, chrome.downloads, chrome.topSites and chrome.sessions are the APIs that reach the user’s own records rather than the page in front of them. They are simple to call and expensive to declare: each one triggers a prominent install-time warning, each one puts your extension into a stricter review lane, and all of them are unavailable to content scripts. This section covers using them well — and deciding, honestly, whether you need them at all. It sits inside Core APIs & Cross-Browser Data Management.
The technical shape is consistent across the five: an async query surface, a small set of change events, and a tree or list model that the browser owns. What differs is how much data flows through them. A bookmark tree is a few thousand nodes; a history query on a heavy user can return tens of thousands of visits, and pulling all of it into a service worker that may be evicted mid-iteration is the single most common way these APIs go wrong.
Prerequisites checklist
Before calling any of these APIs, confirm:
- The specific permission is declared —
"bookmarks","history","downloads","topSites"or"sessions". None of them is implied by any other. - You have decided whether the permission is required at install or requested at runtime. All five can be
optional_permissions, which is almost always the better choice. - The calling context is the service worker or an extension page. None of these namespaces exist in a content script — requests must be routed through message passing.
- Your privacy policy names each category of user data you touch, because the store’s data-disclosure form will ask.
- You have a plan for large result sets that does not involve holding them in worker memory — normally chrome.storage or IndexedDB.
- Change listeners are registered at the top level of the worker, so an event that wakes it is not missed.
Manifest registration
1{
2 "manifest_version": 3,
3 "name": "Reading Log",
4 "version": "1.0.0",
5 "permissions": [
6 "storage" // always needed; the rest are asked for later
7 ],
8 "optional_permissions": [
9 "bookmarks", // requested when the user enables bookmark sync
10 "history", // requested when the user opens the reading report
11 "downloads", // requested when the user exports
12 "topSites",
13 "sessions"
14 ],
15 "background": { "service_worker": "sw.js", "type": "module" }
16}
Execution context: parsed by the browser at install time. Permissions listed under optional_permissions produce no install-time warning at all; the prompt appears when you call chrome.permissions.request from a user gesture, which is both better for conversion and much easier to justify in review — see requesting optional permissions at runtime.
1. Query before you enumerate
Every one of these APIs offers a query that runs inside the browser. Using it instead of fetching everything and filtering in JavaScript is the difference between a 20-millisecond call and a worker that is evicted mid-loop.
1// Good: the browser filters, you receive a bounded list.
2const recent = await chrome.history.search({
3 text: "", // empty matches everything
4 startTime: Date.now() - 7 * 864e5,
5 maxResults: 500,
6});
7
8// Bad: no bound, no filter — this can return tens of thousands of rows.
9const everything = await chrome.history.search({ text: "", startTime: 0, maxResults: 0 });
Execution context: the service worker or an extension page. maxResults: 0 means “no limit” rather than “none”, which is a trap worth knowing before you meet it in production. Firefox and Safari accept the same parameters with the same semantics.
2. Treat change events as notifications, not as data
onCreated, onChanged, onRemoved and their equivalents fire on the user’s activity, which means they can arrive in bursts — importing a bookmark file produces one event per bookmark. Handle them by recording that something changed and reconciling later.
1let dirty = false;
2
3chrome.bookmarks.onCreated.addListener(markDirty);
4chrome.bookmarks.onRemoved.addListener(markDirty);
5chrome.bookmarks.onChanged.addListener(markDirty);
6
7function markDirty() {
8 if (dirty) return;
9 dirty = true;
10 chrome.alarms.create("bookmarks-reconcile", { delayInMinutes: 1 });
11}
Execution context: the top level of the service worker. The dirty flag lives in worker memory and is lost on eviction — which is harmless here, because the alarm is already scheduled and the alarm is the thing that does the work. That pattern is set out in chaining alarms for long-running jobs.
3. Write conservatively
These are the user’s records. A bug in a read is invisible; a bug in a write deletes a bookmark folder someone has kept for a decade.
1// Always confirm the node you are about to modify is the one you found.
2async function renameBookmark(id, expectedUrl, title) {
3 const [node] = await chrome.bookmarks.get(id);
4 if (!node || node.url !== expectedUrl) return false; // it moved or changed under us
5 await chrome.bookmarks.update(id, { title });
6 return true;
7}
Execution context: the service worker. chrome.bookmarks.removeTree has no undo and no confirmation — if your extension ever calls it, the confirmation must come from your own UI first.
4. Route content-script requests through the worker
None of these namespaces exist in a content script, so a page-side feature that needs the user’s bookmarks has to ask the worker for them. Keep the request narrow: send a question, return an answer, never ship the whole tree into a page’s process where any script on that page could reach it through a bug in your own bridge.
1// content script: ask a question
2const { bookmarked } = await chrome.runtime.sendMessage({ type: "bm:has", url: location.href });
3
4// service worker: answer only that question
5handle("bm:has", async ({ url }) => {
6 const hits = await chrome.bookmarks.search({ url });
7 return { bookmarked: hits.length > 0 };
8});
Execution context: the first block runs in the content script’s isolated world, where chrome.runtime is one of the few available namespaces; the second runs in the service worker, where chrome.bookmarks exists. The wrapper is described in wrapping message passing in promises.
5. Deciding whether you need the data at all
Before declaring any of these permissions, it is worth asking whether the feature needs the user’s records or only needs something derived from them. The difference is large: the full history permission produces one of the most alarming install warnings an extension can show, while a lighter surface may answer the same product question with no warning at all.
Several common features have lighter answers. “Show the sites I visit most” is usually served by topSites, a precomputed ranking behind a much milder permission. “Let me reopen what I just closed” is served by sessions.getRecentlyClosed, without reading history. “Know whether this page is bookmarked” needs bookmarks but not history. “Save this page for later” needs neither — the extension can store what the user explicitly saves in its own storage. The comparison is laid out in using the topSites and sessions APIs. Only when the product’s value depends on the user’s full record — a reading report across all sites, a history cleaner, a bookmark reorganiser — is the heavier permission justified, and then it should be requested optionally, at the moment the feature is enabled.
6. Working at the scale of real profiles
Development profiles are small: a few dozen bookmarks, a week of history, a handful of downloads. Real users bring years of accumulated data — ten thousand bookmarks, a hundred thousand history entries — and code written against the small profile often fails on the large one in ways that look like random unreliability.
Three habits handle scale. Bound every query, by time window and by maxResults, so no single call clones an unbounded result into the worker. Process large sets in slices driven by alarms, with a cursor persisted in storage, so the work survives worker eviction and never holds more than one slice in memory. And keep derived results — counts, indexes, summaries — rather than raw copies, so the next read is small. Testing against a synthetic large profile, generated once and loaded into a test browser, catches these problems before users do.
7. Change events in bulk
Bookmark imports, history clears and profile syncs produce events in bursts of thousands. Reacting to each one individually keeps the worker busy for minutes and writes storage thousands of times. Treat change events as a signal that something changed, set a flag, and schedule a single reconciliation with an alarm; when it runs, recompute from the current state rather than replaying each event. The onVisitRemoved event with allHistory: true — a full history clear — is the case most often mishandled: it carries no URL list, and the only correct response is to discard every derived result built from history.
8. Respecting deletions
When a user deletes history, a bookmark or a download, they expect it gone everywhere, including from anything your extension derived from it. An extension that keeps a copy of history in its own storage, or a report that still lists a site the user just deleted, breaks that expectation in a way that feels like surveillance even when it is only a stale cache.
Listen for the removal events and apply them to derived data promptly: drop entries for removed URLs, rebuild aggregates after a range deletion, and clear everything after a full clear. Keep derived data minimal to begin with — counts by domain rather than lists of URLs — so there is less to reconcile. And say plainly in the listing and privacy policy what the extension keeps and for how long, as described in justifying sensitive data permissions.
9. Downloads are different
The downloads API differs from the other two in one important way: it creates things on the user’s disk. That raises the stakes on every call. Ask before choosing a location — saveAs: true for exports the user requested — and never open a downloaded file automatically, which requires the separate, sensitive downloads.open permission for good reason. Clean up list entries your extension created once they complete, but leave the files themselves alone unless the user asks. The patterns for starting, tracking and naming downloads are in managing downloads from an extension.
10. Testing with realistic data
These APIs are easy to test against the browser directly, because the data they read can be created programmatically: bookmarks through bookmarks.create, history through history.addUrl, downloads from local fixtures. An end-to-end setup that seeds a profile with a few thousand entries of each, then runs the extension’s features against them, exercises the bounded queries, slicing and event coalescing described above under conditions close to a real user’s. Seed once per suite rather than per test, since creating thousands of entries takes seconds, and clear them afterwards so the test profile stays predictable.
11. Explaining the data use inside the extension
Store listings and privacy policies are read before install; the extension’s own UI is read every day. When a feature uses bookmarks, history or downloads, say so at the point of use: a line under the reading report explaining that it is computed on this device from the last thirty days of history, a note in the bookmark tool describing exactly which folders it will change. Users who understand what the extension does with their records trust it more, grant optional permissions more readily, and are far less likely to read a permission prompt as a warning sign. Provide a clear way to turn the feature off that also deletes anything derived from the data, so the choice is reversible in both directions.
MV3 constraints to plan around
- No content-script access. All five namespaces are extension-context only. Every page-side feature needs a message round trip and therefore a worker that may be cold.
- Results are structured-cloned. A large history result is copied across a process boundary before your code sees it. That copy is the cost bounded queries avoid.
- No incremental cursor. Unlike IndexedDB, none of these APIs offer a cursor. Pagination is done by narrowing
startTime/endTimewindows and re-querying. - Events can burst. A bookmark import or a history clear produces thousands of events in a moment. Coalesce them behind an alarm rather than reacting per event.
- Deletions are irreversible.
bookmarks.removeTree,history.deleteRangeanddownloads.erasehave no undo. Confirm in your own UI first.
Cross-cutting concerns: permissions, review and privacy
All five permissions produce an install warning that names the data in plain language (“Read and change your browsing history”). Reviewers apply extra scrutiny to any extension that declares them and to any network request made by an extension that holds them. Two rules keep this manageable:
- Ask at the moment of use. A user who just clicked “Show my reading report” understands why history access is needed. The same prompt at install looks like data collection.
- Never transmit the raw records. Derive what you need locally and send the derivation. If you must send URLs, say so explicitly in the listing and the policy — the standard the store applies is described in passing Chrome Web Store review.
Cross-browser compatibility
| API | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
bookmarks | Full tree, bookmarks permission | Full, browser.bookmarks | Supported, limited folder metadata |
history | search, getVisits, delete APIs | Full equivalent | Read-oriented; deletion limited |
downloads | Full, incl. download() and onDeterminingFilename | Full, filename hook differs | Partial — no filename determination |
topSites | get() returns up to 20 | Supported | Not available |
sessions | getRecentlyClosed, restore | Supported | Not available |
What this section covers
The guides below take each API in turn: reading and writing bookmarks safely for the tree model and the destructive calls to fence off; searching and pruning browsing history for querying at scale without stalling the worker; managing downloads from an extension for starting, naming and tracking files; using the topSites and sessions APIs for the two small derived surfaces; and justifying sensitive data permissions for getting all of it through review.
Related
- chrome.storage API and sync — where derived data from these APIs should live.
- Message passing architecture — how a content script reaches APIs it cannot call.
- Tabs API and window management — the other browser-owned surface with its own permission story.
- Store submission and permissions compliance — the review process these permissions trigger.
- Core APIs & Cross-Browser Data Management — the parent section.