Skip to content

Fix: "sidePanel.open() may only be called in response to a user gesture"

6 min readModerok team

Why Chrome throws "sidePanel.open() may only be called in response to a user gesture" after an await, and the call ordering that fixes it.

If Chrome rejects your call with sidePanel.open() may only be called in response to a user gesture. (Chrome prints the method name in backticks) even though your code runs inside a click handler, the cause is almost always an await that happens before the call. Chrome checks the gesture at the moment chrome.sidePanel.open() is invoked, and it only survives the synchronous run of the handler. Read from chrome.storage, query a tab, or call setOptions() first, and by the time open() executes the gesture is gone. The fix is call ordering: invoke open() first, then await everything else.

The rule Chrome is actually enforcing

First, rule out the boring cause. A side panel needs the sidePanel permission and a page to show, and without the side_panel key there is nothing for a global open() call to display:

{
  "manifest_version": 3,
  "name": "My extension",
  "version": "1.0.0",
  "permissions": ["sidePanel"],
  "action": {},
  "side_panel": { "default_path": "sidepanel.html" }
}

chrome.sidePanel.open() landed in Chrome 116 (the Side Panel API itself shipped in Chrome 114). It takes an options object with tabId, windowId, or both, and at least one of the two is required:

chrome.sidePanel.open({ tabId });    // panel for one tab
chrome.sidePanel.open({ windowId }); // global panel in one window

Chrome only permits the call when the extension is holding a live user gesture. "Live" is the important word: this is not a check on where your code sits in the source file, it is a check on the task that is currently executing. Extension APIs do not carry the gesture across promise boundaries, so once your handler yields to the microtask queue, what follows is no longer user-initiated. That is why the error reads as nonsense. The listener really was triggered by a click. The call to open() was not.

The await that breaks it

This is the shape almost every report of this error takes:

// Broken: the gesture is gone by line 4
chrome.action.onClicked.addListener(async (tab) => {
  const { lastPanel } = await chrome.storage.local.get("lastPanel");
  await chrome.sidePanel.setOptions({ tabId: tab.id, path: lastPanel, enabled: true });
  await chrome.sidePanel.open({ tabId: tab.id }); // throws
});

Two awaits, two chances to lose the gesture. Swap the order so open() runs in the same synchronous turn as the listener body:

// Works: open() is the first thing the handler does
chrome.action.onClicked.addListener((tab) => {
  chrome.sidePanel.open({ tabId: tab.id });

  chrome.storage.local.get("lastPanel").then(({ lastPanel }) => {
    chrome.sidePanel.setOptions({
      tabId: tab.id,
      path: lastPanel ?? "sidepanel.html",
      enabled: true,
    });
  });
});

Note that the listener is no longer async. Marking it async does not break the gesture by itself, but it invites the awaits that do.

One caveat: if the panel path depends on state you have to load, you have created a race. The panel opens with whatever path was configured previously, and setOptions() lands afterwards. For dynamic panel content, keep the options up to date ahead of the click:

// reading tab.url requires the "tabs" permission or host permissions
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
  if (!tab.url) return;
  const enabled = new URL(tab.url).origin === "https://example.com";
  await chrome.sidePanel.setOptions({ tabId, path: "sidepanel.html", enabled });
});

Then the click handler has nothing to decide, and open() is a single synchronous line.

Which gestures actually count

Chrome documents the qualifying interaction as an extension user gesture: clicking the action icon, or a user interaction on an extension page or a content script. In practice these are the paths that work:

  • chrome.action.onClicked (only when openPanelOnActionClick is false, see below)
  • chrome.contextMenus.onClicked
  • a click inside your popup, options page, or another extension page
  • a click in a content script, relayed to the service worker

Even these paths have edge cases: developers have filed Chromium bugs about calls that look correctly gestured and are rejected anyway, including one where the second click fails after the user manually closes the panel. If you want a keyboard shortcut, the least fragile setup is openPanelOnActionClick plus a shortcut bound to the reserved _execute_action command, so the panel opens through the action click path instead of your own open() call. Verify it on the oldest Chrome you support.

Opening from a content script click

A content script cannot call chrome.sidePanel.open() itself, so the click has to be relayed. The gesture travels with the message, but only if the receiving listener calls open() before awaiting anything:

// content.js
button.addEventListener("click", () => {
  chrome.runtime.sendMessage({ type: "open-panel" });
});

// service-worker.js
chrome.runtime.onMessage.addListener((msg, sender) => {
  if (msg.type !== "open-panel" || !sender.tab) return;
  chrome.sidePanel.open({ tabId: sender.tab.id });
});

The same ordering rule applies inside onMessage: the body runs synchronously up to its first await, so open() has to come before any of them. Whether a promise returned from onMessage is a valid async-reply signal is a separate question with its own history, covered in why Chrome says the message port closed before a response was received. Opening a panel needs no reply anyway.

Do not dawdle, either. Chrome treats the message as gestured only while the sending frame still holds a transient user activation, and Blink's transient activation lasts about five seconds. A content script that pings the service worker, waits for a slow answer, then sends a second "now open the panel" message can run out of gesture before it asks.

This path is fragile for a second reason: the service worker may be asleep when the message arrives. Chrome starts it and dispatches the event, but a top level await in your worker module delays listener registration. Register listeners at the top level, synchronously, and load state afterwards, the same discipline that keeps a restarting service worker from dropping events.

The version with no gesture problem at all

If all you want is "click my icon, open my panel", do not call open() at all. Let Chrome do it:

chrome.runtime.onInstalled.addListener(() => {
  chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
});

Chromium describes this setting as whether clicking the extension's icon will toggle showing the extension's entry in the side panel, so the second click closes the panel again. The trade is that chrome.action.onClicked does not fire, because Chrome consumed the click: you cannot have both the built-in toggle and a click listener. If you need custom logic on click, set openPanelOnActionClick to false and call open() yourself, using the ordering above.

That built-in toggle is also the cheapest way to get a close affordance. Closing the panel from your own code is newer: close() arrived in Chrome 141 and, like open(), needs a tabId or windowId in its options, so on older Chrome you cannot rely on it at all.

Firefox does not have this API

None of this ports to Firefox, which uses the sidebar_action manifest key and the sidebarAction API, not compatible with sidePanel. It has its own version of the restriction: sidebarAction.open() can only be called from inside the handler for a user action, which includes clicking the extension's toolbar button, selecting an extension context menu item, activating an extension keyboard shortcut, or clicking a button on a page bundled with the extension. Familiar constraint, separate code path. If you ship both browsers, see how a Chrome MV3 background service worker becomes a Firefox event page for how the rest of the background context differs.

Find out how often it fails for real users

Gesture errors are conditional. The panel opens on your machine every time and fails for some users on some Chrome versions, often only on the second click, and nobody files a bug for a button that does nothing. Instrumenting the call turns that into data:

chrome.sidePanel.open({ tabId })
  .then(() => Moderok.track("side_panel_opened", { source: "action" }))
  .catch((err) => Moderok.captureError(err, { where: "sidePanel.open" }));

Because the gesture is checked when open() is invoked, attaching handlers to the returned promise is safe. Moderok's captureError records handled errors as __error events, so a spike in rejected open() calls after a Chrome release shows up next to your usage instead of in a support inbox. The Moderok SDK is a 5.7 kB gzipped drop-in for MV3 service workers with no dependencies and no host_permissions.