Feature Detection vs UA Sniffing
Prefer feature detection over user-agent parsing — @supports, 'in' checks, and when UA hints still appear in the wild.
- javascript
- feature-detection
- user-agent
- compatibility
User-agent sniffing guesses the browser from a string and branches. Feature detection asks: does this environment support the capability I need? Sniffing breaks when engines share code, spoof strings, or ship the feature you blocked. Detection ages better.
Feature detection
// API exists?
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver(callback);
} else {
// scroll fallback or polyfill
}
// method behavior
if (typeof structuredClone === 'function') {
data = structuredClone(obj);
} else {
data = JSON.parse(JSON.stringify(obj));
}
// CSS
if (CSS.supports('display', 'grid')) {
el.classList.add('supports-grid');
}
@supports (display: grid) {
.layout {
display: grid;
}
}
Test the thing you will call, not a brand name.
UA sniffing (why it’s fragile)
// brittle
if (navigator.userAgent.includes('Chrome')) {
enableFancy();
}
Problems:
- Chromium shows up in Edge, Opera, Electron, WebViews.
- Safari/iOS share engines across apps.
- Strings change; crawlers lie.
- You block a browser that already shipped the feature — or allow one that didn’t.
// slightly less wrong but still not capability testing
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// still a sniff — use only for known platform bugs with no feature test
When sniffing still appears
- Fixing a specific engine bug with no reliable capability probe
- Analytics / bot filters (separate threat model)
- Enterprise quirks documentation
Even then, isolate the hack, comment the bug ID, and prefer @supports / API checks when they exist.
// example: existence isn't enough — behavior test
function supportsPassive() {
let passive = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get() {
passive = true;
return false;
},
});
window.addEventListener('test', null, opts);
window.removeEventListener('test', null, opts);
} catch (_) {}
return passive;
}
navigator.userAgentData
Client Hints reduce free-form UA strings but are still identity, not capability:
// Chromium
const brands = navigator.userAgentData?.brands;
Use for metrics maybe; don’t gate features solely on brand.
Progressive enhancement pattern
async function share(data) {
if (navigator.share) {
return navigator.share(data);
}
await navigator.clipboard.writeText(data.url);
toast('Link copied');
}
Offer a baseline; upgrade when APIs exist.
Interview answer
“Feature detection checks whether the API or CSS feature exists or behaves correctly; UA sniffing parses navigator.userAgent and guesses. Detection is preferred because strings lie and engines fork. I use in/window checks, CSS.supports, and behavioral probes; I only sniff for documented engine bugs with no feature test.”
Related
CSS and HTML detection
// input types
const i = document.createElement('input');
i.setAttribute('type', 'date');
const supportsDate = i.type === 'date';
// media queries as feature tests
const finePointer = matchMedia('(pointer: fine)').matches;
const prefersReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
Capability detection extends beyond JS APIs — input types, media queries, and @supports cover UI decisions. Prefer prefers-reduced-motion over sniffing Safari version to disable animations. Keep a tiny matrix of supported baselines (browserslist) so you polyfill deliberately rather than branching on every UA.
Polyfill loading strategy
async function ensureIntlListFormat() {
if (typeof Intl.ListFormat === 'function') return;
await import('@formatjs/intl-listformat/polyfill');
}
Detection + conditional dynamic import loads polyfills only when needed. That pairs with browserslist baselines: ship modern code, polyfill the long tail. Never gate a CSS layout solely on navigator.userAgent when @supports exists.
Further reading
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.
- Async Iteration and for await...ofAsync iterables, for await...of, and streaming data — how async iterators differ from Promise.all and when to use each.