Register and handle keyboard shortcuts in MV3 extensions using the chrome.commands API — manifest declaration, service worker listeners, per-OS suggested keys, the 4-shortcut limit, and cross-browser rebinding via chrome://extensions/shortcuts.
The most dangerous property of the chrome.commands API is what it does not tell you: invalid shortcut combinations are silently dropped at install time, leaving the feature simply absent with no console warning. Commands are declared statically in manifest.json and cannot be registered, removed, or reordered at runtime — every key binding your extension will ever support must exist in the manifest before the user installs it. This guide is part of UI/UX Patterns & Interactive Components and covers the full lifecycle from manifest declaration through service worker event routing to letting users rebind shortcuts via browser settings.
Prerequisites checklist
commands key in manifest.json — required to declare any shortcut. No runtime registration path exists.
Service worker entry point declared in "background": { "service_worker": "background.js" } — this is where onCommand listeners live.
Max 4 shortcuts per extension — Chrome enforces a hard ceiling of four user-assignable commands. The special _execute_action command does not count against this limit.
Valid modifier combinations only — every suggested_key value must use at least one modifier (Ctrl, Alt, Shift, Command, MacCtrl). Modifier-only or bare function-key shortcuts (except F1–F12) are rejected silently.
storage permission — if handlers read or write state via chrome.storage, declare it in "permissions". See Chrome Storage API sync for quota and sync strategy.
1. Registering commands in manifest.json
Every command your extension supports must be declared in the commands object before shipping. The browser parses this key at install or update time and registers the shortcuts with the OS. Any combination the browser considers reserved or malformed is silently ignored — the command is simply absent.
The suggested_key object accepts four platform sub-keys: default (all platforms not explicitly listed), mac, linux, and windows. Omit a sub-key and the default value applies on that platform. On macOS, use Command for the Cmd key and MacCtrl for the hardware Ctrl key — these are distinct modifiers and mixing them up is the single most common macOS shortcut bug.
The special command name _execute_action triggers the extension’s toolbar action (equivalent to clicking the icon) and does not count against the four-shortcut limit. Use it to give power users a keyboard path to the popup without spending one of your four command slots.
Execution context:manifest.json, evaluated by the browser at install or extension update time. No code runs here — these are static declarations. Changing a suggested_key requires publishing an updated extension and the user reloading or updating it. Invalid or reserved combinations are dropped without any error. Run chrome.commands.getAll() in the service worker DevTools console after loading the unpacked extension to confirm registrations.
2. Handling commands in the service worker
The chrome.commands.onCommand event fires in the extension’s service worker whenever a registered shortcut is pressed. Because MV3 service workers are ephemeral — terminated after roughly 30 seconds of inactivity and re-woken on demand — the listener must be registered synchronously at the top level of the background script. Placing it inside a callback, a promise chain, or after a top-level await risks missing events during cold starts.
1// background.js — service worker entry point
2 3// Register at module top level — never inside a callback or async block
4chrome.commands.onCommand.addListener(async(command)=>{ 5switch(command){ 6case'toggle-feature': 7awaithandleFeatureToggle(); 8break; 9case'quick-search':10awaithandleQuickSearch();11break;12default:13// _execute_action is handled by the browser, not dispatched here
14console.warn('Unrecognised command:',command);15}16});1718asyncfunctionhandleFeatureToggle(){19const{featureActive}=awaitchrome.storage.local.get('featureActive');20awaitchrome.storage.local.set({featureActive:!featureActive});21awaitchrome.action.setBadgeText({text:featureActive?'':'ON'});22}2324asyncfunctionhandleQuickSearch(){25const[tab]=awaitchrome.tabs.query({active:true,currentWindow:true});26if(tab?.id){27awaitchrome.tabs.sendMessage(tab.id,{type:'OPEN_SEARCH'});28}29}
Execution context: Service worker (background.js). The onCommand listener fires regardless of which tab is active or whether the popup is open. The service worker may have been terminated since the last command — do not rely on global variable state between invocations. Persist all state to chrome.storage.local inside the handler before any await that could be interrupted. See implementing global keyboard shortcuts safely for the full cold-start timing analysis and a pattern for keeping handlers idempotent.
3. Letting users rebind shortcuts
Chrome provides no API to change a command’s binding programmatically — chrome.commands.update() does not exist. The only way users can reassign shortcuts is through the browser’s own shortcuts manager. Your extension can open that page directly from a popup or options page button to make discovery easier.
1// popup.js or options.js — runs in popup / options page context (not service worker)
2document.getElementById('btn-manage-shortcuts')3.addEventListener('click',()=>{4chrome.tabs.create({url:'chrome://extensions/shortcuts'});5});
Execution context: Popup script or options page script. The chrome://extensions/shortcuts URL works in Chrome and Edge. In Firefox, users manage shortcut overrides at about:addons → the extension’s gear icon → Manage Extension Shortcuts. Safari does not expose a user-facing rebinding interface for extension commands.
Exposing a “Manage keyboard shortcuts” link in your options page or popup is the recommended practice. Pair it with a call to chrome.commands.getAll() to display the current binding so users know what they are changing before they click through.
1// Display current bindings in options page
2asyncfunctionrenderShortcutList(containerEl){3constcommands=awaitchrome.commands.getAll();4containerEl.innerHTML=commands5.filter(cmd=>cmd.shortcut)// skip unassigned commands
6.map(cmd=>`<li><kbd>${cmd.shortcut}</kbd> — ${cmd.description}</li>`)7.join('');8}
Execution context: Options page or popup script. chrome.commands.getAll() returns the currently active binding, which may differ from suggested_key if the user has reassigned it. Use this to show live bindings rather than hard-coding the manifest values into UI text. For strategies on detecting and resolving conflicts between your shortcuts and those of other installed extensions, see resolving keyboard shortcut conflicts.
MV3 Constraints box
Static-only registration. Commands cannot be added, removed, or reordered at runtime. The commands object in manifest.json is the only registration mechanism.
Four-command limit. Chrome allows a maximum of four user-assignable commands per extension. _execute_action, _execute_browser_action, and _execute_page_action are special names that do not count against this limit.
Silent failure on invalid combos. Combinations that conflict with browser or OS reservations — for example Ctrl+W, Ctrl+T, Cmd+Q, F12, Ctrl+Shift+I — are dropped without any warning in the console or manifest validation output.
No DOM access in the service worker. Command handlers run in the background context. To update page UI, use chrome.scripting.executeScript() or chrome.tabs.sendMessage() to reach a content script.
Service worker is ephemeral. The handler may be the first code that runs after a 30-second idle termination. Never read from global variables to determine feature state — always hydrate from chrome.storage.local at the start of the handler.
Global shortcuts fire only when the browser has focus. Shortcuts do not intercept key events at the OS level when the browser window is in the background; they fire only when the browser window is focused.
Cross-browser notes
Chrome and Edge share the same chrome.commands surface and the four-command limit. Both use chrome://extensions/shortcuts for user rebinding. Firefox implements the same manifest syntax under the WebExtensions standard and fires the same onCommand event, but uses the browser.commands namespace — use a polyfill (like webextension-polyfill) or a runtime check to normalise the namespace.
1// Cross-browser namespace normalisation
2constext=typeofbrowser!=='undefined'?browser:chrome;34ext.commands.onCommand.addListener((command)=>{5// handler runs identically on Chrome, Edge, and Firefox
6});
Execution context: Service worker or background script. The ternary resolves at module evaluation time — there is no build-time branching required. Firefox MV3 also supports _execute_action as a special command name matching Chrome’s behaviour. In Firefox, about:addons is the user-facing shortcut manager, not chrome://extensions/shortcuts.
Safari’s WebKit implementation does not support the commands manifest key for MV3 extensions at this time. Shortcut handling on Safari requires falling back to keydown listeners in content scripts or popup pages, which covers only those specific page contexts rather than providing a global trigger.
Browser
chrome.commands support
Rebinding UI
_execute_action
Chrome
Full
chrome://extensions/shortcuts
Supported
Edge
Full (Chromium)
edge://extensions/shortcuts
Supported
Firefox
Full (browser.commands)
about:addons
Supported
Safari
Not supported in MV3
n/a
n/a
When testing shortcut registrations after a manifest change, always force-reload the unpacked extension from chrome://extensions rather than relying on hot-reload tooling — some bundler watchers do not trigger the full manifest re-parse that registers new command entries.