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.
Table of Contents
- Root cause: menus live in the browser, but their creation code lives in an evictable worker
- Step 1. Make registration a single idempotent function
- Step 2. Call it from every entry point that needs it
- Step 3. Keep checkbox and radio state in storage, not in the menu
- Step 4. Update in place when only the state changed
- Step 5. Handle per-tab visibility without rebuilding
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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.
removeAllplus recreate ononInstalledandonStartupcovers every case. - Firefox:
browser.menusis the canonical namespace withbrowser.contextMenusas an alias, and it offers extras Chrome does not, includingmenus.refresh()for updating an open menu and anonShownevent. 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
onStartuppath is the one that matters there. - All engines:
idmust be unique within the extension,type: "separator"items still need one, and a duplicate reports throughlastErrorrather than throwing — so nothing in your code will notice unless you check.
Verification
- 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.
- Restart the browser and confirm the items are present before you interact with the extension at all.
- Disable and re-enable the extension. The menu should return, which is what the self-healing
catchprovides. - Toggle the checkbox item, then force a worker eviction and reopen the menu. The tick should still reflect the stored value.
- 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.
Related
- Dynamic context menu generation based on page content — varying the items by what the page contains.
- Storage onChanged listener patterns — the change feed this menu subscribes to.
- Running data migrations on onInstalled — the other listener sharing the install event.
- Context Menus & Right-Click Actions — the guide this page belongs to.