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.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

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.

Sniffing versus probing, on the day an engine ships a featureA user-agent branch keeps taking the fallback path after the engine gains the API; a capability probe switches over automatically.navigator.userAgent test"is this Firefox?"Firefox ships the APInothing in your code changesStill on the fallbackuntil you ship againsame event, capability probetypeof api === "function""does this exist?"Firefox ships the APIprobe now returns trueNative path, no releasecorrect by construction
The probe needs no release to become correct — that is the entire argument.

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.

What the probe object actually reports per engineEach probed capability and whether it resolves true in Chrome, Firefox and Safari.ProbeChrome 120+Firefox 121+Safari 17+sidePanel.opentruefalsefalsestorage.session.gettruetrue16.4+storage.session.setAccessLeveltruefalsefalseidentity.getAuthTokentruefalsefalsescripting.registerContentScriptstrue112+17+runtime.requestUpdateChecktruefalsefalse
Read this as documentation of today — the code reads it fresh on every browser version.

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.

Choosing the right kind of probePresence probes cover missing APIs, behaviour probes cover APIs that exist but differ, and a stored result covers probes that are expensive.Does the API exist everywhere but behave differently?No, it is absentPresence probetypeof x === "function"Free, synchronous, at module loadcache in a constantYesBehaviour probecall it once and inspectCache in storage.sessiononce per browser sessionOnly under loadDo not probehandle the error path insteadQuota and throttling are runtime stat…not capabilities
Reach for a behaviour probe only when presence genuinely cannot answer the question.
 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 chrome namespace is native and returns Promises in MV3. browser is undefined unless you load a polyfill. Edge reports Chromium capabilities and should never be special-cased.
  • Firefox: both browser and chrome exist, with browser as the Promise-native original. Probing chrome.* works, but the canonical namespace is browser.*, which is why the cross-browser namespace adapter matters for a shared codebase.
  • Safari: exposes browser and a chrome alias. 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.userAgent in extension code. It is spoofable, it is changed by the user, and it tells you nothing about the extension APIs you have.

Verification

  1. Log the whole can object in each engine’s worker console and check it against what you expect — surprises here are usually a typo in a nested property name.
  2. Comment out a capability at the source (chrome.sidePanel = undefined) and confirm the fallback path runs rather than throwing.
  3. Search the codebase for userAgent. Any hit outside a presentation module is a bug.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources