Reading and Writing Bookmarks Safely

Walk the chrome.bookmarks tree without stalling an MV3 worker, write updates that cannot clobber the user's folders, and handle the change events an import fires by the thousand.

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

The bookmarks API hands you the user’s own filing system, complete with folders they have curated for years, and offers removeTree with no confirmation and no undo. It also returns the entire tree in a single call, which on a large profile is a structured clone of tens of thousands of nodes into a worker that may be evicted before you finish walking it. Both risks are avoidable. This guide is part of bookmarks, history and downloads APIs.

The tree you are handed

chrome.bookmarks.getTree() returns a single root whose children are the browser’s top-level folders — the bookmarks bar, “other bookmarks”, and on some platforms a mobile folder. Their ids are not stable constants across browsers, so never hard-code "1" or "2".

The shape of the bookmark treeA root node with browser-owned top-level folders, each containing user folders and leaf bookmarks distinguished by the presence of a url property.root (id '0')never shown to the userBookmarks barbrowser-owned folderOther bookmarksbrowser-owned folderchildren are user folders and leavesUser folderno url propertyBookmarkurl, title, dateAddedSeparatorFirefox only
A node with a url is a bookmark; a node without one is a folder. There is no type field.

Step-by-step

1. Search instead of walking, when you can

Most questions — “is this page bookmarked”, “where did they file this domain” — are a query, not a traversal.

1const hits = await chrome.bookmarks.search({ url: "https://example.com/article" });
2const bookmarked = hits.length > 0;
3
4// Free-text search across titles and URLs:
5const matches = await chrome.bookmarks.search("release notes");

Execution context: the service worker or an extension page. search with an object matches exactly on the fields you give; search with a string does a fuzzy match over title and URL. Neither is available in a content script.

2. Walk iteratively, not recursively

A recursive walk over a deep tree can exhaust the stack on pathological profiles and, more practically, makes it impossible to stop halfway. An explicit stack does both.

 1async function* walk() {
 2  const [root] = await chrome.bookmarks.getTree();
 3  const stack = [...(root.children ?? [])];
 4  while (stack.length) {
 5    const node = stack.pop();
 6    yield node;
 7    if (node.children) stack.push(...node.children);
 8  }
 9}
10
11let count = 0;
12for await (const node of walk()) {
13  if (node.url) count++;
14}

Execution context: the service worker. getTree clones the whole tree in one go, so the generator is iterating an in-memory structure — the cost is the single clone, not the walk. For very large trees, prefer getChildren level by level so you never hold the whole thing at once.

3. Fetch level by level for large trees

1async function* walkLazily(parentId = "0") {
2  const children = await chrome.bookmarks.getChildren(parentId);
3  for (const node of children) {
4    yield node;
5    if (!node.url) yield* walkLazily(node.id);   // folders only
6  }
7}

Execution context: the service worker. Each getChildren is a separate call, which keeps the worker alive through the traversal and keeps peak memory proportional to one folder rather than the whole profile.

4. Guard every write with a re-read

The tree can change between your read and your write — the user drags a folder, another extension reorganises, a sync lands. Confirm the node is still what you think it is.

1async function moveIfUnchanged(id, expected, destination) {
2  const [node] = await chrome.bookmarks.get(id).catch(() => []);
3  if (!node) return "gone";
4  if (node.url !== expected.url || node.parentId !== expected.parentId) return "moved";
5  await chrome.bookmarks.move(id, destination);
6  return "ok";
7}

Execution context: the service worker. chrome.bookmarks.get rejects rather than resolving empty when the id no longer exists, which is why the call is wrapped — a removed bookmark is a normal outcome, not an error.

5. Fence off the destructive calls

1// One place that can delete, and it refuses browser-owned folders outright.
2const PROTECTED_DEPTH = 1;   // direct children of root are browser folders
3
4export async function removeSubtree(id, { confirmed }) {
5  if (!confirmed) throw new Error("removeSubtree requires explicit confirmation");
6  const [node] = await chrome.bookmarks.get(id);
7  if (node.parentId === "0") throw new Error("refusing to remove a top-level folder");
8  await chrome.bookmarks.removeTree(id);
9}

Execution context: the service worker. The confirmed flag is not security — it is a speed bump that makes an accidental call from a refactor fail loudly instead of deleting a folder. The user-facing confirmation belongs in your own UI, before this is reached.

6. Coalesce the event storm

Importing an HTML bookmarks file fires onCreated once per bookmark. Reacting per event will hammer storage and can keep the worker alive for minutes.

 1let scheduled = false;
 2
 3for (const ev of [chrome.bookmarks.onCreated, chrome.bookmarks.onChanged,
 4                  chrome.bookmarks.onRemoved, chrome.bookmarks.onMoved]) {
 5  ev.addListener(() => {
 6    if (scheduled) return;
 7    scheduled = true;
 8    chrome.alarms.create("bm-reconcile", { delayInMinutes: 1 });
 9  });
10}
11
12chrome.alarms.onAlarm.addListener(async (a) => {
13  if (a.name !== "bm-reconcile") return;
14  scheduled = false;
15  await rebuildIndex();
16});

Execution context: the top level of the service worker. The scheduled flag is lost if the worker is evicted, which is harmless — the alarm is already registered and the reconcile runs regardless.

A bookmark import, with and without coalescingTwo timelines over ninety seconds: one reacting to every onCreated event, one scheduling a single reconcile a minute after the first event.import starts+90 sEvents arrive~4,000 onCreatedQuietalarm pendingOne rec…~2 sIdleworker evictedfirst event schedules the alarmalarm fires once
The coalesced run does the same work once, off the event path, with the worker awake for seconds rather than the whole import.

Building an index the user’s tree can change under

Most bookmark features are really a lookup: is this URL saved, and where. Answering that by searching on every page load is fine at a few hundred bookmarks and noticeably slow at ten thousand, because each search call is a cross-process query. The alternative is an index you own — and an index over data the user edits directly needs a rebuild strategy, not just a build one.

1async function rebuildIndex() {
2  const byUrl = Object.create(null);
3  for await (const node of walkLazily()) {
4    if (!node.url) continue;
5    (byUrl[node.url] ??= []).push({ id: node.id, parentId: node.parentId, title: node.title });
6  }
7  await chrome.storage.local.set({ bmIndex: byUrl, bmIndexAt: Date.now() });
8}

Execution context: the service worker, driven from the coalescing alarm in step 6 rather than from an event. On a ten-thousand-node tree this takes a few hundred milliseconds and produces an object small enough for storage.local — but large enough that you should not write it on every change, which is the whole reason for the alarm.

The index is a cache, so the interesting design question is what happens when it is wrong. Two rules keep it honest. First, treat a hit as provisional: confirm with chrome.bookmarks.get before acting on it, exactly as in step 4, so a stale entry produces a re-read rather than a wrong write. Second, record bmIndexAt and treat an index older than a day as absent — a profile that synced overnight may have changed without firing a single event in this browser session.

There is one case the events genuinely do not cover: a bookmark changed on another device and delivered by profile sync. Chrome fires onCreated and onRemoved for synced changes in most builds, but the timing is not guaranteed and Firefox’s behaviour differs. The daily expiry is what covers the gap, and it costs one rebuild per day.

Answering "is this bookmarked?" three waysA live search per query, a cached index rebuilt on change, and a hybrid that checks the index then confirms, compared on latency, staleness and complexity.PropertyLive searchCached indexIndex then confirmLatency per check5–40 msUnder 1 msUnder 1 msCan be staleNeverYesOnly transientlyCost of a write pathNoneRebuild per changeRebuild per changeSurvives sync from another deviceYesOnly with expiryYes
The hybrid is what most extensions converge on — index latency with live correctness on the path that matters.

Cross-browser variation

  • Chrome / Edge: root children are the bookmarks bar and “other bookmarks”, with ids that are stable within a profile but not documented as constants. chrome.bookmarks.update accepts only title and url.
  • Firefox: exposes separators as nodes with type: "separator", which Chrome has no concept of — a walk that assumes “no url means folder” will treat them as empty folders. Firefox also has a type field Chrome lacks.
  • Safari: supports the API but omits some metadata, and folder reordering is less reliable. Treat index as advisory rather than authoritative on Safari.
  • All three: the permission produces a prominent install warning. Requesting it as an optional permission at the moment the user enables a bookmark feature is strongly preferable — see justifying sensitive data permissions.

Verification

  1. Count nodes without holding the tree, from the service worker console:
1let folders = 0, links = 0;
2for await (const n of walk()) (n.url ? links++ : folders++);
3({ folders, links });
4// { folders: 92, links: 3417 }

Execution context: the service worker console. If this throws a clone error, the profile is large enough that you should be using the lazy walk from step 3.

  1. Create a bookmark, then confirm your reconcile ran exactly once rather than per event.
  2. Attempt removeSubtree on a top-level folder id and confirm it throws rather than deleting.
  3. Rename a bookmark from the browser UI while your extension holds an id, then run the guarded move and confirm it reports moved instead of writing.

FAQ

Are bookmark ids stable?

Within a profile, yes — an id refers to the same node until it is deleted. Across profiles, devices and browsers they are not, so never sync an id. Sync the URL and the folder path instead.

Can I create a bookmark in a folder that does not exist yet?

No — create needs a valid parentId. Create the folder first (a create call with a title and no url), then use its returned id.

Why did my extension’s bookmark disappear?

Most often because it was created in a folder the user later deleted, or because a profile sync reconciled two conflicting trees. Re-verify by URL rather than by id when a bookmark you created is unexpectedly missing.

Other Core APIs & Cross-Browser Data Management Resources