Fix "Cannot create item with duplicate id" in a Chrome extension
6 min readModerok team
Why an MV3 service worker hits "Cannot create item with duplicate id", the chrome.runtime.onInstalled fix, and what still breaks after it.
If your extension logs Unchecked runtime.lastError: Cannot create item with duplicate id my-menu, you are calling chrome.contextMenus.create() at the top level of a Manifest V3 service worker. The menu item already exists from the last time the worker ran, and the ids have to be unique. Move the call into a chrome.runtime.onInstalled listener:
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: "my-menu",
title: "Do the thing",
contexts: ["selection"],
});
});
That is the whole fix. The rest is why the error happens, why it never throws, and the follow-on bugs that appear once the text is gone.
Why the service worker creates the item twice
Two facts collide. The first is that menu item ids are unique per extension and Chrome refuses a second registration under an id it already has. The id property in the chrome.contextMenus reference is documented as "The unique ID to assign to this item. Mandatory for event pages. Cannot be the same as another ID for this extension."
The second is that your background script is not a program that runs once. An MV3 service worker is torn down when it goes idle and its top-level code is evaluated from scratch on every wake (see why your service worker keeps stopping). The menu item is not torn down with it: Chromium stores the menu registrations of any extension with a lazy background context and reads them back when the extension loads, both gated on BackgroundInfo::HasLazyContext(extension) in MenuManager (source). That is why your menu survives a browser restart with nothing in your code recreating it.
So the second evaluation of contextMenus.create({ id: "my-menu" }) runs against a browser that still has my-menu, and Chromium rejects it. The exact string comes from context_menu_helpers.cc, where kDuplicateIDError is "Cannot create item with duplicate id *" and the * is filled in with your id (source).
The gate is that lazy background context, not the manifest version: an MV2 event page ("persistent": false) is lazy the same way and hits the same error, which is why the reference describes the id property as "Mandatory for event pages" rather than mentioning MV3. MV3 removed the choice.
The error never throws, so try/catch does nothing
chrome.contextMenus.create() does not reject and does not throw on a duplicate id. The reference says: "Creates a new context menu item. If an error occurs during creation, it may not be detected until the creation callback fires; details will be in runtime.lastError."
The return value is the id of the new item, not a promise, so await chrome.contextMenus.create(...) hands back the id you passed in and says nothing about whether the item was created. The Chromium schema marks the call "does_not_support_promises" for this reason, and MDN says the same for menus.create(): "Unlike other asynchronous functions, this one does not return a promise, but uses an optional callback to communicate success or failure. This is because its return value is the ID of the new item."
If you want to see the failure, pass the callback and read chrome.runtime.lastError inside it:
chrome.contextMenus.create({ id: "my-menu", title: "Do the thing" }, () => {
if (chrome.runtime.lastError) {
console.warn("menu create failed:", chrome.runtime.lastError.message);
}
});
Without the callback the failure appears as an unchecked lastError in the service worker console and nowhere else. Catching chrome.runtime.lastError in MV3 covers the pattern.
Where menu creation actually belongs
Chrome's service worker lifecycle documentation is explicit that onInstalled is the place for this: "Use this event to set a state or for one-time initialization, such as a context menu." MDN gives the mechanism: for non-persistent backgrounds, "You call menus.create (with a menu-specific ID) from within a runtime.onInstalled listener. This avoids repeated attempts to create the menu item when the pages restart, which would occur with a top-level call."
There is a catch inside the fix, because onInstalled fires for more than installs. The same page lists all three triggers: it is "fired when the extension (not the service worker) is first installed, when the extension is updated to a new version, and when Chrome is updated to a new version." That last one is the problem. On a Chrome update your extension is not reinstalled, so its items are still registered, and an unguarded create() in the listener collides with them and logs the duplicate id error again, just far more rarely.
Chrome's own sample guards on details.reason; clearing first is the other option. Unlike create(), removeAll() does support promises in MV3, so a rebuild that is safe on all three reasons is a few lines:
chrome.runtime.onInstalled.addListener(async () => {
await chrome.contextMenus.removeAll();
chrome.contextMenus.create({ id: "translate", title: "Translate", contexts: ["selection"] });
chrome.contextMenus.create({ id: "translate-page", title: "Translate page", contexts: ["page"] });
});
What removeAll() does not buy you, despite advice to the contrary, is cleanup across versions. Chromium already does that: the state store drops every stored key belonging to an extension when it is installed or updated (StateStore::OnExtensionWillBeInstalled calls RemoveKeysForExtension without checking the is_update flag it is handed), so an item you deleted in v1.4 is gone for v1.3 upgraders before your listener runs.
The API is gated on "permissions": ["contextMenus"] in the manifest, and is unavailable in content scripts.
What still breaks after the fix
Registering onClicked inside onInstalled. This is the bug the fix invites, and it is worse than the one you solved: the menu appears but clicking it does nothing after the first worker shutdown. onInstalled fires on install, extension update and Chrome update, none of which is "every worker start", so a listener registered inside it is gone on the next wake. Keep the click handler at the top level and only the create() calls inside onInstalled:
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === "my-menu") handleClick(info, tab);
});
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({ id: "my-menu", title: "Do the thing", contexts: ["selection"] });
});
Menus that depend on state. A title that reflects a user setting cannot be rebuilt on a worker wake without hitting the duplicate id error again. Use chrome.contextMenus.update(id, props) wherever the state changes instead.
Reloading an unpacked extension counts as an install. Per the lifecycle docs, "Installation occurs when the user installs or updates a service worker from the Chrome Web Store or when they load or update an unpacked extension using the chrome://extensions page," so the listener reruns on every dev reload. That is why this error is common in development and rare in production.
Two neighbouring errors. Omitting the id gets you "Extensions using event pages or Service Workers must pass an id parameter to chrome.contextMenus.create", and passing an onclick function is refused too: the reference says onclick "is not available inside of a service worker; instead, you should register a listener for contextMenus.onClicked."
Silent limits. ACTION_MENU_TOP_LEVEL_LIMIT is 6, and the reference notes that "Any items beyond this limit will be ignored". Chromium also caps an extension at 1,000 items (kMaxItemsPerExtension), after which create() fails with a different lastError.
Firefox differences
Firefox implements this as browser.menus and mirrors it, per MDN, into contextMenus: "For compatibility with other browsers, Firefox makes this method available in the contextMenus namespace and menus namespace." The alias is not total, though: MDN adds that "it's not possible to create tools menu items (contexts: ["tools_menu"]) using the contextMenus namespace."
The persistence rule flips with the background type. Firefox MV3 uses an event page rather than a service worker (see Firefox event page vs Chrome service worker), and for event pages the onInstalled pattern above is correct in both browsers. If you still ship a persistent background page, MDN is direct: "in Firefox, menu items from persistent background pages are never persisted. Call menus.create unconditionally from the top level to register the menu items." A shared background file that only creates menus inside onInstalled produces no menu at all in that configuration.
Knowing it failed in the field
A broken context menu produces no crash, no failed request, and no support ticket. It shows up as an unchecked lastError in a console nobody has open.
That is the gap Moderok's error capture is meant for. chrome.runtime.lastError is not collected automatically by anything, so the SDK takes it explicitly: Moderok.captureLastError("contextMenus.create", chrome.runtime.lastError, { id: "my-menu" }) in the creation callback records the failure as an error event you can read in the dashboard, next to the custom events that say whether the item is clicked at all. The error tracking guide has the full API.