Feature Detection Instead of Browser Sniffing
Probe for the MV3 capability you need rather than the engine you think you are on: safe probes, a capability map, graceful degradation, and why user-agent checks rot.
Table of Contents
- Root cause: the engine is a proxy for the thing you actually care about
- Step 1. Probe the exact member you will call
- Step 2. Use the capability, not the browser, at every call site
- Step 3. Probe behaviour when presence is not enough
- Step 4. Declare capabilities where users can see them
- Step 5. Keep the one legitimate engine check honest
- Cross-browser variation
- Verification
- FAQ
- Related
if (navigator.userAgent.includes("Firefox")) works until Firefox ships the API you were routing around, or until Edge reports a string you did not anticipate, or until a Chromium fork with a different name installs your extension. The check does not describe what your code needs — it describes a guess about which engines have it, made on the day you wrote it. This page is part of Cross-Browser API Compatibility and replaces those guesses with probes that stay correct as the engines move.
Root cause: the engine is a proxy for the thing you actually care about
Every browser check is really a capability question wearing a disguise. “Is this Firefox?” means “is chrome.identity.getAuthToken missing?”. “Is this Safari?” means “does chrome.sidePanel exist?”. Asking the real question is shorter, cannot go stale, and works on engines you have never heard of.
Step 1. Probe the exact member you will call
Check the deepest thing you depend on. chrome.storage existing tells you nothing about chrome.storage.session.
1// capabilities.js
2export const can = {
3 sidePanel: typeof chrome.sidePanel?.open === "function",
4 sessionStorage: typeof chrome.storage?.session?.get === "function",
5 storageAccessLvl: typeof chrome.storage?.session?.setAccessLevel === "function",
6 nativeGoogleAuth: typeof chrome.identity?.getAuthToken === "function",
7 dynamicScripts: typeof chrome.scripting?.registerContentScripts === "function",
8 updateCheck: typeof chrome.runtime?.requestUpdateCheck === "function",
9 offscreen: typeof chrome.offscreen?.createDocument === "function",
10 userScripts: typeof chrome.userScripts?.register === "function",
11};
Execution context: Evaluated once at module load, in the service worker or any extension page. Optional chaining is what makes this safe — chrome.sidePanel is undefined in Firefox and Safari, and a plain property access on it would throw during worker startup and take down every listener registration below it. Freezing the results in a module constant is deliberate: capabilities cannot change while the extension is running, so probing repeatedly is wasted work in the hot path.
Step 2. Use the capability, not the browser, at every call site
1// panel.js
2import { can } from "./capabilities.js";
3
4export async function openPanel(tabId) {
5 if (can.sidePanel) {
6 await chrome.sidePanel.open({ tabId });
7 return "side-panel";
8 }
9 if (typeof browser !== "undefined" && typeof browser.sidebarAction?.open === "function") {
10 await browser.sidebarAction.open(); // Firefox's own, differently shaped API
11 return "sidebar";
12 }
13 await chrome.windows.create({ url: "/panel.html", type: "popup", width: 420, height: 640 });
14 return "window";
15}
Execution context: Service worker, from a user-gesture handler — both sidePanel.open and sidebarAction.open require one in their respective engines. The third branch is the point: a fallback that works everywhere means the feature degrades rather than disappears, and you never have to enumerate which engines lack the API. The differences between these three surfaces are covered in side panel support across browsers.
Step 3. Probe behaviour when presence is not enough
Some APIs exist and behave differently. A presence check cannot see that, so probe the behaviour once and cache the answer.
1// capabilities.js
2export async function probePromiseStyle() {
3 const { probes = {} } = await chrome.storage.session.get("probes");
4 if (typeof probes.promiseNative === "boolean") return probes.promiseNative;
5
6 // Chrome's chrome.* returns a Promise in MV3; a polyfilled namespace may not.
7 const result = chrome.storage.local.get("__probe__");
8 const promiseNative = typeof result?.then === "function";
9 if (promiseNative) await result; // do not leave it floating
10
11 await chrome.storage.session.set({ probes: { ...probes, promiseNative } });
12 return promiseNative;
13}
Execution context: Service worker. Caching in chrome.storage.session rather than a module variable means the answer survives worker eviction but is recomputed after a browser restart, which is the right lifetime for something that can only change with a browser update. Awaiting the probe promise when it exists avoids an unhandled rejection warning if the storage call fails. Do not use this pattern for quota or throttling states — those are runtime conditions, not capabilities, and belong in error handling such as handling storage quota exceeded errors.
Step 4. Declare capabilities where users can see them
A feature that silently does nothing on one engine reads as a bug. Reflect the probe in the interface.
1// options.js
2import { can } from "./capabilities.js";
3import { t } from "./i18n.js";
4
5for (const [key, available] of Object.entries(can)) {
6 const row = document.querySelector(`[data-capability="${key}"]`);
7 if (!row) continue;
8
9 row.hidden = false;
10 row.querySelector("input").disabled = !available;
11 row.querySelector(".note").textContent = available ? "" : t("capabilityUnavailable");
12}
Execution context: Options page document, external script. Disabling rather than hiding the control is usually better: a hidden setting looks like a missing feature, while a disabled one with an explanation tells the user their browser is the constraint. Keep the explanation in the message bundle so it is translated along with everything else, as described in localising extension UI with the i18n API.
Step 5. Keep the one legitimate engine check honest
There is a narrow case where the engine genuinely matters: rendering documentation, a store link, or a keyboard shortcut label that differs by platform. Even then, prefer a positive signal over a negative one.
1// platform.js — for presentation only, never for control flow
2export async function platformHint() {
3 const info = await chrome.runtime.getPlatformInfo(); // { os, arch }
4 const isGecko = typeof browser !== "undefined" && browser.runtime === chrome.runtime;
5 return { os: info.os, family: isGecko ? "gecko" : "chromium-or-webkit" };
6}
Execution context: Any extension surface; getPlatformInfo is available in all three engines and returns the operating system rather than the browser, which is what a shortcut label actually depends on — Cmd versus Ctrl is a macOS question, not a Safari one. Confining engine detection to presentation and naming the module accordingly is what stops it leaking back into logic six months later.
Cross-browser variation
- Chrome/Edge: the
chromenamespace is native and returns Promises in MV3.browseris undefined unless you load a polyfill. Edge reports Chromium capabilities and should never be special-cased. - Firefox: both
browserandchromeexist, withbrowseras the Promise-native original. Probingchrome.*works, but the canonical namespace isbrowser.*, which is why the cross-browser namespace adapter matters for a shared codebase. - Safari: exposes
browserand achromealias. Several APIs are present but stubbed, which is exactly the case that needs a behaviour probe rather than a presence check. - All engines: never branch on
navigator.userAgentin extension code. It is spoofable, it is changed by the user, and it tells you nothing about the extension APIs you have.
Verification
- Log the whole
canobject in each engine’s worker console and check it against what you expect — surprises here are usually a typo in a nested property name. - Comment out a capability at the source (
chrome.sidePanel = undefined) and confirm the fallback path runs rather than throwing. - Search the codebase for
userAgent. Any hit outside a presentation module is a bug. - Load the options page in Firefox and confirm the unavailable capabilities are disabled with an explanation rather than missing.
FAQ
Is optional chaining enough, or do I need a try/catch?
Optional chaining covers missing objects and members, which is every realistic case. A try/catch is only needed around a behaviour probe that actually invokes the API.
Should I probe at module load or lazily?
At module load for presence probes — they are free. Behaviour probes should be lazy and cached, because they cost a real API call.
What about polyfills that fake an API?
A polyfill that installs a working implementation is exactly what you want the probe to find. One that installs a stub that throws is worse than nothing; audit before adopting.
Does this apply to CSS and DOM features too?
Yes, with CSS.supports() and the usual DOM probes. Extension pages are ordinary documents and the same reasoning applies.
Related
- Shipping one manifest for Chrome and Firefox — the build-time half of the same problem.
- Handling Safari web extension conversion gaps — where behaviour probes earn their keep.
- Background script support across browsers — the one capability you cannot probe at runtime.
- Cross-Browser API Compatibility — the guide this page belongs to.