Building a Capability Matrix for Your Extension

Replace scattered browser checks with one capability table — detect features once at startup, cache the result, and drive UI, manifest generation and release notes from it.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

By the third browser target, capability checks have spread everywhere: a typeof chrome.sidePanel here, a try/catch around storage.session there, a comment in the popup explaining why a button is hidden on Firefox. Each is correct and the set is unmaintainable, because nothing tells you what the extension can do on a given engine without reading all of it. A capability matrix collapses that into one module. This guide is part of cross-browser API compatibility.

What belongs in the matrix

A capability is a feature your product has, not an API. “Side panel” is a capability; chrome.sidePanel.setOptions is an implementation detail. Framing them at product level is what lets the popup ask one question — can I offer this? — instead of reimplementing the detection.

From engine surface to a product decisionRaw API presence is probed once, folded into named product capabilities, cached in session storage, and read by the UI and the worker as a single flag.UI and feature codeif (caps.sidePanel) …never touches chrome.* directlyCapability mapnamed product featuresone module, one tableProbestypeof / in / try-callcheap, synchronous where possibleEngine surfacechrome.* / browser.*differs per browser and version
Only the bottom layer knows about chrome.* — everything above it asks about features.

Step-by-step

1. Write the probes

Keep each probe to one line and make it total — a probe must never throw, because one broken check would take the whole matrix with it.

 1// capabilities.js
 2const probe = (fn) => { try { return !!fn(); } catch { return false; } };
 3
 4export function detect() {
 5  const api = globalThis.chrome ?? globalThis.browser;
 6  return {
 7    sidePanel:      probe(() => api.sidePanel?.open),
 8    sidebarAction:  probe(() => api.sidebarAction?.open),
 9    offscreen:      probe(() => api.offscreen?.createDocument),
10    sessionStorage: probe(() => api.storage?.session),
11    dnrSession:     probe(() => api.declarativeNetRequest?.updateSessionRules),
12    userScripts:    probe(() => api.userScripts?.register),
13    mainWorld:      probe(() => api.scripting?.ExecutionWorld?.MAIN),
14    commandsUpdate: probe(() => api.commands?.update),
15  };
16}

Execution context: any extension context — the worker, the popup, the options page. Optional chaining keeps each probe from throwing on a missing namespace, and the try wrapper catches the rarer case where merely reading a property throws.

2. Fold probes into named capabilities

 1export function capabilities() {
 2  const p = detect();
 3  return {
 4    persistentPanel: p.sidePanel || p.sidebarAction,      // either surface will do
 5    clipboardWrite:  p.offscreen,                          // needs a DOM context
 6    perTabBlocking:  p.dnrSession,
 7    ephemeralState:  p.sessionStorage,
 8    pageWorldBridge: p.mainWorld || p.userScripts,
 9    rebindShortcuts: p.commandsUpdate,
10  };
11}

Execution context: any extension context. Note persistentPanel is true on both Chrome and Firefox through different APIs — exactly the collapse that makes the calling code simpler, with the surface choice pushed down into the adapter described in side panel support across browsers.

3. Compute once, cache for the session

Detection is cheap but not free, and more importantly you want every context to agree. Compute in the worker on startup and publish to chrome.storage.session.

 1// service-worker.js
 2import { capabilities } from "./capabilities.js";
 3
 4async function publishCapabilities() {
 5  const caps = capabilities();
 6  try {
 7    await chrome.storage.session.set({ caps });
 8  } catch {
 9    await chrome.storage.local.set({ caps });   // Safari < 16.4, Firefox < 115
10  }
11}
12
13chrome.runtime.onStartup.addListener(publishCapabilities);
14chrome.runtime.onInstalled.addListener(publishCapabilities);

Execution context: the service worker. Writing to storage.session means the matrix is recomputed after every browser restart, which is what you want — a browser update can add a capability without your extension changing at all.

4. Read it from the UI without a round trip

1// popup.js
2const { caps = {} } = await chrome.storage.session.get("caps");
3
4document.querySelector("#open-panel").hidden = !caps.persistentPanel;
5document.querySelector("#copy-btn").disabled = !caps.clipboardWrite;
6document.querySelector("#shortcuts-link").hidden = !caps.rebindShortcuts;

Execution context: the popup document, which has its own renderer and its own chrome.* bindings. Reading from storage rather than messaging the worker means the popup renders even when the worker is asleep — see why the popup closes and how to work with it.

5. Drive the build from the same table

The matrix is also the honest source for what each build supports, so generate the per-target manifest and the release notes from it rather than maintaining a second list.

1// build/targets.js
2export const TARGETS = {
3  chrome:  { minVersion: 114, expects: ["persistentPanel", "clipboardWrite", "perTabBlocking"] },
4  firefox: { minVersion: 121, expects: ["persistentPanel", "ephemeralState"] },
5  safari:  { minVersion: 17,  expects: ["ephemeralState"] },
6};

Execution context: the build script in Node, not the browser. A smoke test can load each build and assert that capabilities() reports at least everything in expects — turning a silent regression into a failing job.

A worked matrix for a reader extensionSix product capabilities resolved against Chrome 114+, Firefox 121+ and Safari 17+, showing which surface satisfies each one.CapabilityChrome 114+Firefox 121+Safari 17+persistentPanelchrome.sidePanelsidebarActionNoneclipboardWriteoffscreen documentbackground DOMpage context onlyperTabBlockingsession rulessession rulesstatic rules onlyephemeralStatestorage.sessionstorage.sessionstorage.sessionpageWorldBridgeworld: MAINuserScriptsinjected script tagrebindShortcutscommands UIcommands.updateSystem Settings
The row a product manager cares about is the leftmost column; the engine columns are an implementation consequence.

Keeping the matrix honest as browsers move

A capability table is a cache of facts about the world, and the world changes without telling you. Chrome ships an API you had marked unavailable; Firefox implements a surface you had written a fallback for; an enterprise policy disables something everyone else has. All three produce the same symptom — a fallback path running where the real feature would now work — and none of them produce an error.

Recomputing on every browser restart handles the first two automatically, which is the main argument for storage.session over storage.local in step 3. The remaining risk is a capability that was probed once and cached for months on a machine that is never restarted; a secondary recompute when the extension updates covers that.

The third case — policy — is worth a specific check, because it is the one that makes an extension look broken to exactly the users least able to debug it.

1// A probe that calls rather than inspects, for surfaces policy can disable.
2async function canUseDnr() {
3  try {
4    await chrome.declarativeNetRequest.getEnabledRulesets();
5    return true;
6  } catch {
7    return false;            // policy-blocked, or the API is stubbed
8  }
9}

Execution context: the service worker. Existence probes cannot see a policy block: the namespace and the method are both present, and only the call fails. Reserve call-probes for the few surfaces where this matters — they are more expensive and, done carelessly, have side effects.

The other maintenance task is pruning. A capability that has been true on every supported engine for two years is not a capability any more; it is an assumption, and leaving it in the table means leaving a dead fallback branch in the code that nobody tests. Delete both together, and raise strict_min_version in the same release so the assumption is enforced rather than hoped for.

When the matrix is recomputedA timeline showing recomputation at browser start and at extension update, with the long stretch in between covered by cached values.install3 months laterPro…onI…Cached in session storagereads are freeBro…rep…CachedweeksExt…repr…Chrome shipped a new API herefallback branch retired
A machine that is never restarted is the one that keeps a stale matrix — which is why the update hook matters too.

Cross-browser variation

  • Chrome / Edge: presence of a namespace is a reliable signal; Chrome does not ship stub objects for APIs it has not implemented. Edge tracks Chrome but can trail by a release, so probe rather than assuming Chromium parity.
  • Firefox: exposes browser.* and aliases much of chrome.*, so probing globalThis.chrome still works. Firefox-only surfaces like sidebarAction must appear in the matrix or the fallback branch will never be chosen.
  • Safari: the one engine that does ship partially implemented APIs — a namespace can exist while a method resolves with an empty result. For Safari, prefer a probe that calls the method with a harmless argument over one that checks for its existence.
  • All three: never key the matrix off the user agent. A version string tells you nothing about an enterprise policy that disabled an API, and it goes stale the moment a browser ships the feature.

Verification

  1. From each browser’s background console, print the resolved matrix:
1Object.entries((await chrome.storage.session.get("caps")).caps)
2  .filter(([, v]) => !v)
3  .map(([k]) => k);
4// Firefox → ["clipboardWrite", "perTabBlocking"]

Execution context: the background console of the browser under test. The list of missing capabilities should match the column for that engine in your table; anything unexpected is either a probe bug or a genuine surprise worth writing down.

  1. Confirm the popup hides exactly the affected controls and that no control throws when clicked on the engine where it is disabled.
  2. Run the build smoke test for each target and confirm expects is satisfied.

FAQ

Should the matrix be async?

The probes are synchronous, so capabilities() can be. Publishing and reading it involves storage, which is async. Keep the pure function synchronous so tests can call it with a mocked namespace, as in mocking Chrome APIs in Jest.

What about capabilities that depend on a permission the user has not granted?

Model those as a second, separate check. A capability answers “can this browser do it”; a permission answers “am I allowed to right now”. Merging them makes an ungranted optional permission look like an unsupported browser — see requesting optional permissions at runtime.

Does caching risk a stale matrix?

Only until the next browser restart, because storage.session is cleared then. If you want it fresher, recompute in the popup as well and compare — a mismatch is a useful signal that the browser updated underneath you.

Other Core APIs & Cross-Browser Data Management Resources