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.
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.
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.
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.
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 ofchrome.*, so probingglobalThis.chromestill works. Firefox-only surfaces likesidebarActionmust 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
- 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.
- Confirm the popup hides exactly the affected controls and that no control throws when clicked on the engine where it is disabled.
- Run the build smoke test for each target and confirm
expectsis 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.
Related
- Feature detection instead of browser sniffing — the principle this table operationalises.
- Shipping one manifest for Chrome and Firefox — how the same table shapes the manifest.
- Typing chrome and browser APIs in TypeScript — making the compiler enforce the guarded branches.
- Cross-browser API compatibility — the parent reference for engine differences.