Keeping Context Menu IDs Stable Across Updates

Avoid duplicate-id errors, orphaned items and broken handlers when an MV3 extension updates — removeAll-then-create, namespaced ids, and migrating renamed menu items safely.

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

Context menu items persist in the browser across worker restarts and browser restarts, which is convenient right up until the extension updates. Then three things can go wrong at once: create throws “Cannot create item with duplicate id” for items the previous version registered, items the new version no longer defines linger with no handler, and a renamed id leaves the old item visible alongside the new one. All three are avoidable with one discipline. This guide is part of context menus and right-click actions.

What survives, and when it is rebuilt

The life of a context menu itemItems are created on install, survive worker evictions and browser restarts, and are cleared when the extension updates or reloads, at which point onInstalled must recreate them.installsecond updateonInst…v1 itemsWorker evictionsitems persistBrowser restartsitems persistUpdate…v2 item…Normal useitems persistcreate() onceremoveAll() then create()
The update boundary is the only point at which the menu is rebuilt — and the only place the rebuild belongs.

Chrome’s behaviour here has shifted over time and differs slightly from Firefox’s, which is exactly why relying on “items are cleared on update” is fragile. The robust pattern does not care whether they were cleared.

Step-by-step

1. Rebuild from scratch on install and update

1import { MENU } from "./menu-definition.js";
2
3chrome.runtime.onInstalled.addListener(async () => {
4  await chrome.contextMenus.removeAll();
5  for (const item of MENU) {
6    chrome.contextMenus.create(item);
7  }
8});

Execution context: the service worker, registered at the top level. removeAll first makes the rebuild idempotent: whatever the previous version left behind — renamed ids, retired features, duplicate leftovers — is gone before the current definition is applied. The approach to when this runs is covered in rebuilding context menus after worker restarts.

2. Keep the menu definition as data

1// menu-definition.js
2export const MENU = [
3  { id: "reader", title: "Reader", contexts: ["page", "selection", "link"] },
4  { id: "reader:save", parentId: "reader", title: "Save page", contexts: ["page"] },
5  { id: "reader:quote", parentId: "reader", title: "Save “%s” as a quote", contexts: ["selection"] },
6  { id: "reader:later", parentId: "reader", title: "Read link later", contexts: ["link"] },
7];

Execution context: a module imported by the service worker. Parents must appear before their children because create is processed in order and a child with an unknown parentId is rejected. A data definition also makes the menu testable: a unit test can assert that every id is unique and every parentId exists.

3. Namespace ids by feature, never by position

1// Fragile: meaning depends on order, and "item-2" gets reused
2{ id: "item-2", title: "Save page" }
3
4// Stable: meaning is in the id, and it is never reused
5{ id: "reader:save", title: "Save page" }

Execution context: the menu definition. Handlers dispatch on the id, so an id that changes meaning between versions routes clicks to the wrong code. A namespaced, descriptive id also makes logs and error reports readable without a lookup table.

4. Treat an id as a public name

Once an id has shipped, other things may depend on it: a stored preference (“hide reader:later”), analytics, a support document. Renaming is therefore a migration, not a refactor.

1const RENAMED = { "save-link": "reader:later", "save": "reader:save" };
2
3chrome.runtime.onInstalled.addListener(async ({ reason }) => {
4  if (reason !== "update") return;
5  const { hiddenMenuItems = [] } = await chrome.storage.sync.get("hiddenMenuItems");
6  const migrated = hiddenMenuItems.map((id) => RENAMED[id] ?? id);
7  await chrome.storage.sync.set({ hiddenMenuItems: migrated });
8});

Execution context: the service worker, as part of the update migration. The pattern is the same one used for storage schemas in running data migrations on onInstalled — the id is a key in the user’s data, so it gets the same care.

5. Handle clicks on ids you no longer define

A menu can outlive its handler for a short window around an update, and a click arriving in that window should not throw.

1chrome.contextMenus.onClicked.addListener((info, tab) => {
2  const handler = HANDLERS[info.menuItemId];
3  if (!handler) {
4    console.warn("[menu] click on unknown item", info.menuItemId);
5    return;
6  }
7  return handler(info, tab);
8});

Execution context: the service worker. An unknown id is a signal worth logging — it means the rebuild did not run or ran partially — but not worth an exception the user would see as a broken extension.

6. Test the definition

1import { MENU } from "../src/menu-definition.js";
2
3const ids = MENU.map((m) => m.id);
4assert.equal(new Set(ids).size, ids.length, "duplicate menu id");
5for (const m of MENU) {
6  if (m.parentId) assert.ok(ids.indexOf(m.parentId) < ids.indexOf(m.id), `parent after child: ${m.id}`);
7}
8for (const id of ids) assert.ok(id in HANDLERS || MENU.some((m) => m.parentId === id), `no handler: ${id}`);

Execution context: Node, under your test runner. Three assertions cover the three failures that otherwise surface only after publishing: a duplicate id, a child declared before its parent, and a leaf item with no handler.

An update that renames and retires itemsOn update, removeAll clears every item the old version left, the new definition is created in parent-first order, and stored references to renamed ids are migrated.onInstalled(update)previousVersion 2.3contextMenus.removeAll()old items gonecreate(MENU…)parents firstand in the user's dataMap renamed idssave-link → reader:laterDrop retired idsfrom stored prefsRefresh checked statefrom storage
Because removeAll runs first, the old version's leftovers never need to be enumerated.
Update-time menu failures and their causesFour symptoms seen after an extension update — duplicate id errors, ghost items, clicks doing nothing, and lost user preferences — mapped to cause and fix.SymptomCauseFixDuplicate id errorcreate() over surviving itemsremoveAll() firstGhost item from old versionRetired id never removedremoveAll() firstClick does nothingHandler map missing the idTest ids against HANDLERSUser preference lostId renamed without migrationMigrate stored ids
The first two rows disappear entirely with removeAll-then-create; the last two need the id to be treated as data.

Why the menu is part of your public surface

It is easy to think of context menu items as implementation detail, because they are defined in a few lines of JavaScript. From the user’s side they are the opposite: a menu entry is something they learned, found by muscle memory, and perhaps wrote down in a team wiki (“right-click → Reader → Save quote”). Changing the label, moving an item into a sub-menu or removing it is a user-visible change in the same way that moving a button is.

That argues for a small amount of process around the menu definition. Treat changes to menu-definition.js like changes to a public API: mention them in release notes, keep labels stable where possible, and when an item must move, consider leaving the old location in place for one release with a title that points to the new one. The id work described above is what makes that kind of gentle transition possible — an item can be renamed in the UI while its id, and every preference that references it, stays the same.

It also argues for keeping the menu small. Every item added is an item that must be maintained, migrated and explained for the life of the extension. The cheapest menu item to keep stable is the one never shipped.

Cross-browser variation

  • Chrome / Edge: menu items persist across worker and browser restarts. Recreating an existing id throws “Cannot create item with duplicate id”; removeAll is the cleanest guard.
  • Firefox: browser.menus items are registered per extension session and are more reliably cleared on update, but the same removeAll-then-create pattern is correct there and costs nothing.
  • Safari: behaviour after an app update has been less predictable. Rebuilding on onInstalled and again on onStartup if the menu appears empty is a reasonable belt-and-braces approach.
  • All three: create accepts a callback and reports errors through lastError; in promise-returning builds, an error rejects. Either way, a duplicate id is a symptom of a missing removeAll, not something to swallow.

Verification

  1. Install the previous release, then load the new build over it and right-click: exactly the new items should appear, with no duplicates and none of the retired ones.
  2. Check the service worker console for “duplicate id” errors during the update — there should be none:
1chrome.contextMenus.create({ id: "reader:save", title: "x", contexts: ["page"] }, () => {
2  console.log(chrome.runtime.lastError?.message);   // "Cannot create item with duplicate id reader:save"
3});

Execution context: the service worker console, after startup. Seeing the duplicate error here confirms the item already exists — expected at this point, and exactly what removeAll prevents during the rebuild.

  1. Store a preference referencing an old id, update, and confirm it was migrated.
  2. Run the definition tests.

FAQ

Is removeAll on every startup too aggressive?

It is unnecessary rather than harmful — items persist across restarts, so rebuilding on onStartup only costs a few calls. Doing it on onInstalled is enough in Chrome; add onStartup if you have seen empty menus on a particular browser.

Can I remove just the items I no longer use?

You can call remove(id) for each, but you must know every id every previous version created. removeAll needs no such history, which is why it is the recommended form.

Do menu ids have to match between Chrome and Firefox builds?

They should — a shared definition module keeps them identical, and a shared handler map then works in both.

Other UI/UX Patterns & Interactive Components Resources