Rebuilding Context Menus After Worker Restarts

Context menus persist while the extension does — until they do not. Recreate them idempotently on install and startup, avoid duplicate-id errors, and restore checkbox state.

Published August 1, 2026 Updated August 1, 2026 9 min read
Table of Contents

Your menu items appear after install and are still there a week later, so you conclude they persist. Then a user reports they vanished after a browser restart, or you see Cannot create item with duplicate id filling the worker console, and it becomes clear the rule is more subtle than “created once, there forever”. This page is part of Context Menus & Right-Click Actions and covers a registration routine that is correct on install, on startup, after an update, and after an eviction.

Root cause: menus live in the browser, but their creation code lives in an evictable worker

chrome.contextMenus.create registers an item with the browser, which keeps it for the extension’s session — so it survives worker eviction. It does not survive a browser restart in every engine, and it does not survive an extension update. Meanwhile the code that creates it lives in a worker that is torn down constantly and re-evaluated on every wake-up. Get the two lifetimes confused and you either lose the menu or create it twice.

What survives each kind of restartWorker eviction, browser restart, extension update and reinstall compared on whether registered context menus survive.EventMenus surviveWorker re-evaluatedonInstalled firesWorker eviction and wakeYesYesNoBrowser restartNot guaranteedYesNoExtension updateNoYesYesExtension reloadNoYesYesDisable and re-enableNoYesNo
Only the first column is safe — which is why registration must run from onStartup as well as onInstalled.

The last row is the one that produces the “menus vanished” report: disabling and re-enabling an extension clears its menus and fires neither onInstalled nor, in some versions, onStartup — so registration that runs only from those two events leaves the user with nothing until they restart the browser.

Step 1. Make registration a single idempotent function

Write one function that produces the correct menu from scratch, and call it from every entry point rather than trying to work out which ones need it.

 1// menus.js
 2export async function installMenus(settings) {
 3  // removeAll first: it is cheap, it cannot fail on a missing item, and it makes the
 4  // function safe to call at any time from any entry point.
 5  await chrome.contextMenus.removeAll();
 6
 7  chrome.contextMenus.create({
 8    id: "save-selection",
 9    title: "Save “%s” to Tab Curator",     // %s expands to the selected text
10    contexts: ["selection"],
11  });
12
13  chrome.contextMenus.create({ id: "sep-1", type: "separator", contexts: ["selection", "link"] });
14
15  chrome.contextMenus.create({
16    id: "save-link",
17    title: "Save this link",
18    contexts: ["link"],
19    targetUrlPatterns: ["https://*/*"],      // never offer it for javascript: or data: URLs
20  });
21
22  chrome.contextMenus.create({
23    id: "auto-save",
24    title: "Save automatically on this site",
25    type: "checkbox",
26    checked: Boolean(settings.autoSaveHosts?.length),
27    contexts: ["page"],
28  });
29}

Execution context: Service worker. chrome.contextMenus.create is synchronous in the sense that it returns the id immediately, but it reports failures through chrome.runtime.lastError in the optional callback rather than by throwing — which is why a duplicate id produces a console error rather than a rejected promise you can catch. removeAll sidesteps the whole class of problem. The %s placeholder is expanded by the browser with the current selection in all three engines and is truncated automatically, so no length guard is needed.

Step 2. Call it from every entry point that needs it

 1// sw.js — top level, all registered synchronously
 2import { installMenus } from "./menus.js";
 3
 4async function refreshMenus() {
 5  const settings = await chrome.storage.sync.get(["autoSaveHosts"]);
 6  await installMenus(settings);
 7}
 8
 9chrome.runtime.onInstalled.addListener(refreshMenus);   // install, update, browser update
10chrome.runtime.onStartup.addListener(refreshMenus);     // every browser launch

Execution context: Service worker, listeners registered during script evaluation. Both are required and neither is sufficient: onInstalled does not fire on a browser launch, and onStartup does not fire on an update or when the extension is enabled mid-session. Do not call refreshMenus at bare script evaluation as well — that runs on every wake-up, and rebuilding the menu dozens of times an hour is wasted work that also resets any checkbox state the user just toggled.

Which events must rebuild the menuonInstalled covers install and update, onStartup covers browser launch, and a settings change rebuilds to reflect new state; plain worker wake-ups must not rebuild.onInstalledinstall, update, reloadonStartupbrowser launchstorage.onChangedsettings that alter the menuWorker wake-updo NOT rebuildthe first three call the same functionremoveAll() then create()idempotent by construction
The greyed branch is the trap — rebuilding on every wake-up looks harmless and resets user state.

Step 3. Keep checkbox and radio state in storage, not in the menu

The menu is a view. If the item’s checked flag is the only record of the setting, it is lost the moment the menu is rebuilt.

One checkbox click, and where the truth lives afterwardsThe browser flips the tick itself and reports the new state; the handler writes it to storage, and the next rebuild reads it back from there.UserBrowser menuWorkerstorage.syncclicks the checkbox itemflips the tick itselfonClicked({ checked: true })state after the clickset({ autoSaveHosts })onChanged → update the item title
info.checked is the state AFTER the click — reading it as the previous state inverts the setting.
 1// sw.js
 2chrome.contextMenus.onClicked.addListener(async (info, tab) => {
 3  if (info.menuItemId !== "auto-save") return;
 4
 5  const host = new URL(tab.url).hostname;
 6  const { autoSaveHosts = [] } = await chrome.storage.sync.get("autoSaveHosts");
 7
 8  const next = info.checked
 9    ? [...new Set([...autoSaveHosts, host])]
10    : autoSaveHosts.filter((h) => h !== host);
11
12  await chrome.storage.sync.set({ autoSaveHosts: next });
13  // The browser already flipped the tick; storage is now the source of truth for next time.
14});

Execution context: Service worker, top-level listener so a cold start still receives the click. info.checked reports the state after the click, which is the opposite of what people usually assume and the cause of settings that toggle backwards. Reading tab.url requires the tabs permission or an activeTab grant — a context menu click does grant activeTab for that tab, so no broad host permission is needed here. Writing to storage.sync means the preference follows the user to another machine, and the onChanged listener in the next step keeps the menu in step.

Step 4. Update in place when only the state changed

A full rebuild for a title change is heavy-handed and briefly makes the menu disappear if the user has it open.

 1// sw.js
 2chrome.storage.onChanged.addListener(async (changes, area) => {
 3  if (area !== "sync" || !changes.autoSaveHosts) return;
 4
 5  const hosts = changes.autoSaveHosts.newValue ?? [];
 6  await chrome.contextMenus.update("auto-save", {
 7    checked: hosts.length > 0,
 8    title: hosts.length ? `Auto-saving on ${hosts.length} site(s)` : "Save automatically on this site",
 9  });
10});

Execution context: Service worker. contextMenus.update fails silently through lastError if the id does not exist — which happens if the menu was cleared and not yet rebuilt, for example immediately after the extension is re-enabled. Wrapping it in a catch that falls back to refreshMenus() makes the whole system self-healing. Chrome and Firefox both support update; Safari supports it but re-renders the menu lazily, so a change made while the menu is open may not appear until it is reopened.

Step 5. Handle per-tab visibility without rebuilding

Menus are global, not per tab. To vary them by page, use documentUrlPatterns when creating, or visible when updating on tab activation.

1// sw.js
2chrome.tabs.onActivated.addListener(async ({ tabId }) => {
3  const tab = await chrome.tabs.get(tabId);
4  const injectable = /^https?:/.test(tab.url ?? "");
5
6  await chrome.contextMenus.update("auto-save", { visible: injectable })
7    .catch(() => refreshMenus());        // the item was gone — rebuild and try again
8});

Execution context: Service worker. Hiding rather than removing keeps the id stable, so the update in step 4 continues to work. Note the tab.url guard: without the tabs permission the field is absent on tabs the extension has no access to, and /^https?:/ on undefined is false, which is the safe default. On a chrome:// page the item stays hidden, which is better than offering an action that would fail. Firefox supports visible from version 63; Safari supports it, and both ignore the update silently if the id is missing rather than throwing.

Cross-browser variation

  • Chrome/Edge: menus persist for the browser session and are cleared on update, reload and disable. removeAll plus recreate on onInstalled and onStartup covers every case.
  • Firefox: browser.menus is the canonical namespace with browser.contextMenus as an alias, and it offers extras Chrome does not, including menus.refresh() for updating an open menu and an onShown event. Both are Firefox-only, so keep them behind a capability probe.
  • Safari: supports the core API. Menu items are rebuilt when the containing app relaunches the extension, so the onStartup path is the one that matters there.
  • All engines: id must be unique within the extension, type: "separator" items still need one, and a duplicate reports through lastError rather than throwing — so nothing in your code will notice unless you check.

Verification

  1. Install the extension and confirm the items appear. Reload from the extensions page and confirm they are still correct with no duplicate-id errors in the worker console.
  2. Restart the browser and confirm the items are present before you interact with the extension at all.
  3. Disable and re-enable the extension. The menu should return, which is what the self-healing catch provides.
  4. Toggle the checkbox item, then force a worker eviction and reopen the menu. The tick should still reflect the stored value.
  5. Switch to a chrome:// tab and confirm the page-context item is hidden rather than present and broken.

FAQ

Why do I get duplicate-id errors only sometimes?

Because the creation code is running from a path that fires more than once — usually bare script evaluation, which runs on every worker wake-up. removeAll first makes it harmless either way.

Can I create menus lazily, when the user right-clicks?

Not in Chrome — there is no event before the menu opens. Firefox has menus.onShown plus menus.refresh(), which allows it, but the code has to work without them everywhere else.

Do menus count against any quota?

There is no documented limit, but Chrome groups an extension’s items under a single submenu once there are more than one, so a long list is a usability problem before it is a technical one.

Should the click handler be registered at the top level?

Yes, always. A click wakes the worker, and a handler registered after an await misses the event that woke it.

Other UI/UX Patterns & Interactive Components Resources