Context Menus & Right-Click Actions

Build chrome.contextMenus items in MV3: register in onInstalled, handle onClicked in the service worker, create parent/child menus, and gate visibility by context type.

The most common mistake developers make with chrome.contextMenus in Manifest V3 is calling chrome.contextMenus.create() at the top level of the service worker script. Because service workers restart frequently and shed all in-memory state on termination, a top-level create() call runs on every worker wake-up. Each call throws Unchecked runtime.lastError: Cannot create item with duplicate id after the first installation, silently poisoning the background and making menus unreliable. The fix is always to wrap registration inside chrome.runtime.onInstalled, where it fires exactly once per install or update.

Context menus are a first-class surface in the UI/UX Patterns & Interactive Components toolkit precisely because they integrate with the browser’s native OS chrome. They require no popup, no badge, and no user-initiated interaction — a right-click is enough. That low-friction entry point is powerful, but it comes with constraints: no custom styling, a hard item limit, and execution entirely inside the service worker with all of its ephemeral lifecycle implications.

The registration lifecycle

The diagram below shows the complete path from installation to click handling.

Context menu registration and click lifecycle in MV3Flow diagram: onInstalled fires in the service worker, calls contextMenus.create, which registers items in the browser. When the user right-clicks a page, the browser displays the native menu. Selecting an item fires onClicked back in the service worker, where the handler executes.Service Workerruntime.onInstalledfires once per installcontextMenus.create()registers itemsid, title, contextsBrowser Menu Storepersists acrossworker restartsUSER INTERACTIONUser Right-Clicksbrowser showsnative OS menucontextMenus.onClickedwakes service workerinfo + tab passed inHandler Executesmessage, scripting,storage, fetch…Menu items persist in the browser even when the service worker is not running

The key insight from the diagram: the browser menu store is independent of the service worker. Once create() runs inside onInstalled, the items persist until the extension is updated or removed. The worker can terminate and restart freely — the menu stays registered. What does not persist is the onClicked listener itself, which is why you must register it as a top-level statement in the service worker script (not inside a callback), so it re-attaches every time the worker starts.

Prerequisites checklist

Before writing any context menu code, confirm the following are already in place:

  • "contextMenus" listed in the permissions array in manifest.json (Chrome/Edge). Firefox also accepts "menus" as an alias and exposes the API under browser.menus.
  • A service worker declared as "background": { "service_worker": "background.js" } in manifest.json. Review service worker fundamentals if this is not yet configured.
  • If you plan to inject scripts from the onClicked handler, "scripting" and "activeTab" permissions are required.
  • If the handler writes results to storage, confirm "storage" is declared and your chrome.storage strategy is decided.
  • Host permissions ("<all_urls>" or a pattern) if you intend to use documentUrlPatterns to restrict menus to specific origins.

1. Manifest declaration

 1// manifest.json
 2{
 3  "manifest_version": 3,
 4  "name": "My Extension",
 5  "version": "1.0",
 6  "permissions": [
 7    "contextMenus",   // required to call chrome.contextMenus.*
 8    "activeTab",      // lets onClicked handler access the triggering tab
 9    "scripting",      // only if you'll inject scripts from the handler
10    "storage"         // only if persisting handler results
11  ],
12  "background": {
13    "service_worker": "background.js"
14    // type: "module" is optional but enables ES module imports
15  }
16}

Execution context: Parsed by the browser at install time; not executed code. No thread considerations apply here.

2. Registering menus in onInstalled

All calls to chrome.contextMenus.create() must live inside chrome.runtime.onInstalled. Calling create() at the top level of the service worker causes a duplicate-ID error on every worker restart after the initial install.

 1// background.js
 2
 3// Top-level listener registration — re-attaches every time the worker starts
 4chrome.runtime.onInstalled.addListener(({ reason }) => {
 5  // Remove all existing items first to guarantee a clean slate on update
 6  chrome.contextMenus.removeAll(() => {
 7    // Parent item — visible on any page element
 8    chrome.contextMenus.create({
 9      id: "ext-parent",
10      title: "My Extension",
11      contexts: ["all"]
12    });
13
14    // Child: fires when text is selected
15    chrome.contextMenus.create({
16      id: "ext-selection",
17      parentId: "ext-parent",
18      title: "Analyze \"%s\"",   // %s is replaced with selected text
19      contexts: ["selection"]
20    });
21
22    // Child: fires on right-click of any hyperlink
23    chrome.contextMenus.create({
24      id: "ext-link",
25      parentId: "ext-parent",
26      title: "Save this link",
27      contexts: ["link"]
28    });
29
30    // Child: fires on right-click of any image
31    chrome.contextMenus.create({
32      id: "ext-image",
33      parentId: "ext-parent",
34      title: "Inspect image",
35      contexts: ["image"]
36    });
37
38    // Separator before a toggle item
39    chrome.contextMenus.create({
40      id: "ext-separator",
41      parentId: "ext-parent",
42      type: "separator",
43      contexts: ["all"]
44    });
45
46    // Checkbox item — persists checked state via storage
47    chrome.contextMenus.create({
48      id: "ext-toggle",
49      parentId: "ext-parent",
50      type: "checkbox",
51      title: "Auto-process selections",
52      checked: false,
53      contexts: ["all"]
54    });
55  });
56});

Execution context: Service worker, inside the onInstalled event. removeAll() followed by create() in its callback is the safest pattern for handling extension updates — it prevents stale items left over from a previous version’s menu structure. The worker may be short-lived; all logic here completes synchronously before the event completes.

The contexts array is the gating mechanism for visibility. The supported values are "all", "page", "selection", "link", "image", "video", "audio", "editable", "frame", and "launcher" (Chrome OS only). A menu item only appears in the right-click menu when the current context matches at least one of the declared values. The "%s" placeholder in a title string is automatically replaced by the browser with the currently selected text when the context includes "selection".

Parent/child relationships are created by setting parentId to the id of a previously created item. The browser enforces that the parent must exist before the child is created — order matters.

3. Handling clicks in the service worker

The onClicked listener must be registered at the top level of the service worker so it re-attaches on every worker wake-up. Registering it only inside onInstalled means it disappears after the first worker termination.

 1// background.js (continued — top-level, not inside any callback)
 2
 3chrome.contextMenus.onClicked.addListener(async (info, tab) => {
 4  switch (info.menuItemId) {
 5
 6    case "ext-selection": {
 7      const text = info.selectionText ?? "";
 8      if (!text) return;
 9      // Pass the payload to a content script via message passing
10      await chrome.tabs.sendMessage(tab.id, {
11        type: "ANALYZE_SELECTION",
12        payload: text
13      });
14      break;
15    }
16
17    case "ext-link": {
18      // info.linkUrl is the href of the right-clicked anchor
19      const { savedLinks = [] } = await chrome.storage.local.get("savedLinks");
20      savedLinks.push({ url: info.linkUrl, savedAt: Date.now() });
21      await chrome.storage.local.set({ savedLinks });
22      break;
23    }
24
25    case "ext-image": {
26      // info.srcUrl is the src of the right-clicked image
27      await chrome.scripting.executeScript({
28        target: { tabId: tab.id },
29        func: (src) => {
30          console.log("Inspecting image:", src);
31        },
32        args: [info.srcUrl]
33      });
34      break;
35    }
36
37    case "ext-toggle": {
38      // info.checked reflects the new state after the click
39      await chrome.storage.local.set({ autoProcess: info.checked });
40      break;
41    }
42
43    default:
44      console.warn("Unhandled context menu item:", info.menuItemId);
45  }
46});

Execution context: Service worker. The handler is async, which is fine — the browser keeps the worker alive while the Promise returned by the listener is pending (in Chrome 116+ / Firefox with MV3 support). For longer operations such as a network fetch, consider using chrome.offscreen or posting a message to a content script to avoid worker termination. info.selectionText is only populated when contexts includes "selection". info.linkUrl requires contexts: ["link"]. info.srcUrl requires contexts: ["image"], "video", or "audio".

Updating items at runtime

You can change a menu item’s title, visibility, or checked state after creation using chrome.contextMenus.update(). A common pattern is toggling item visibility based on per-tab state. For the most sophisticated version of this — building menus whose structure and titles are generated from the actual content of the page the user right-clicked — see the guide on dynamic context menu generation based on page content.

 1// Toggle a menu item's visibility from anywhere in the service worker
 2async function setAnalysisItemVisible(visible) {
 3  await chrome.contextMenus.update("ext-selection", { visible });
 4}
 5
 6// React to storage changes (e.g., user disables a feature in the popup)
 7chrome.storage.onChanged.addListener((changes, area) => {
 8  if (area === "local" && "featureEnabled" in changes) {
 9    setAnalysisItemVisible(changes.featureEnabled.newValue);
10  }
11});

Execution context: Service worker. chrome.contextMenus.update() returns a Promise in Chrome 88+; the callback form is also supported. storage.onChanged is a top-level persistent listener that wakes the worker to deliver changes.

MV3 Constraints box

  • No top-level create() calls. The service worker restarts frequently; every restart re-executes the top-level script. Calling create() outside onInstalled generates a duplicate-ID error on the second and every subsequent startup.
  • Hard item limit: 1,000 items per extension. This includes all items across all contexts. Exceeding this limit silently fails in some browser versions.
  • No custom styling. Context menus render using the native OS theme. You cannot apply CSS, custom fonts, or icons beyond the small bitmap icons field (16×16 pixels).
  • No inline HTML. The title field is plain text only. HTML tags are rendered as literal characters.
  • documentUrlPatterns cannot use file:// URLs unless the extension also has the "file://*" host permission granted by the user in extension settings.
  • chrome.scripting.executeScript in the onClicked handler requires both "scripting" and "activeTab" (or a matching host permission) — "activeTab" alone is not sufficient for executeScript in MV3.
  • Worker termination during async handlers. Although Chrome extends the worker lifetime while a listener’s returned Promise is pending, this guarantee has limits. Fetch operations that take more than 30 seconds will be cut off. Use chrome.offscreen documents for sustained background work.
  • No access to window, document, or the DOM in the service worker. All UI interaction must go through message passing to content scripts. See message passing architecture for patterns.

Cross-browser notes

Chrome, Edge, and Firefox all support the contextMenus / menus API in MV3 extensions, but there are meaningful divergences.

Namespace: Chrome and Edge use chrome.contextMenus. Firefox exposes the same functionality under both browser.menus (preferred, Promise-based) and chrome.contextMenus (via compatibility layer, callback-based). Using chrome.contextMenus in Firefox works but returns undefined from async calls rather than Promises. Use browser.menus in Firefox when you need the return value.

"menus" permission alias: Firefox accepts "menus" as an alias for "contextMenus" in the permissions array. Chrome ignores "menus" and requires "contextMenus". Safe cross-browser practice: declare both.

visible property: chrome.contextMenus.update({ visible: false }) is supported in Chrome 62+. Firefox’s browser.menus.update() also supports visible. This is the preferred way to hide items without removing and recreating them.

type: "radio": Fully supported in Chrome and Firefox. Items with the same groupId (Firefox) or sharing the same parent (Chrome convention) act as a radio group. Edge mirrors Chrome behavior exactly.

documentUrlPatterns vs targetUrlPatterns: documentUrlPatterns matches the URL of the page the user right-clicked. targetUrlPatterns matches the URL of the element (image src, link href) the user right-clicked and is only available in Chrome 88+. Firefox does not support targetUrlPatterns; use info.srcUrl / info.linkUrl inside the onClicked handler instead.

Icons on menu items: Chrome supports an icons field on create() to display a 16×16 bitmap next to the item title. Firefox does not support this field and ignores it silently.

Safari / WebExtensions: Safari 15.4+ added MV3 support. The contextMenus API surface in Safari matches Chrome’s, but type: "radio", type: "separator", and documentUrlPatterns have limited or inconsistent support as of early 2026. Test thoroughly on Safari before shipping menu-heavy features.

Practical cross-browser shim:

1// background.js — resolve once, use everywhere
2const menusAPI = typeof browser !== "undefined" && browser.menus
3  ? browser.menus          // Firefox: native Promise API
4  : chrome.contextMenus;   // Chrome, Edge, Safari

Execution context: Service worker. This assignment executes once when the worker script is evaluated. typeof browser is the correct guard — browser is not defined in Chrome’s service worker scope, so a direct reference without typeof throws a ReferenceError.