bowser
bowser parses a User-Agent string into structured facts: browser name and version, OS name and version, platform type (desktop, mobile, tablet, tv, bot), and rendering engine. You call Bowser.getParser(uaString) and then ask questions of the result, either field by field with getBrowserName() and friends, or declaratively with satisfies(), which takes a tree of version ranges per browser, OS, and platform and answers yes or no. It can also read User-Agent Client Hints (navigator.userAgentData) as a second argument, which is the only way to tell Chromium-based browsers apart reliably now. It has no dependencies and runs in both browsers and Node.
The cleanest UA parser on npm, and satisfies() is the reason to pick it over the alternatives. The honest caveat is the category, not the library: sniff only when you have nothing but a string to work from, and feature-detect everywhere else.
Use it if
- You only have the User-Agent string, which is the normal situation server-side: enriching request logs, analytics rows, or crash reports where there is no JavaScript runtime to ask
- You need to show an unsupported-browser banner and want the version comparison written for you: satisfies({macos: {safari: '>10.1'}, chrome: '~20.1'}) beats hand-rolled regex plus semver
- You want the whole breakdown rather than one boolean: os.versionName, platform.type, and engine.name in one parse, without pulling in a device-database-sized package
- You want to combine Client Hints with UA parsing: passing navigator.userAgentData as the second argument improves accuracy for DuckDuckGo, Brave, and other Chromium forks that otherwise all look like Chrome
- You are choosing a code path based on capability: feature detection ('fetch' in window, CSS.supports) is correct today and stays correct, while a UA check silently starts lying the moment a browser changes its string, which they do to escape exactly this kind of sniffing
- You need precise Chrome versions: Chrome's User-Agent Reduction freezes the minor, build, and patch numbers at zeros, so a rule like '>131.0.6778' cannot ever match and only the major number is real
- You expect satisfies() to be a boolean: it returns undefined when the current browser is not mentioned in your check tree, so if (!browser.satisfies(rules)) blocks every browser you forgot to list, including new ones
- You care about tree shaking or bundle size for one lookup: there is no exports map and named exports were added in 2.13.0 and reverted in 2.13.1, so you get the whole UMD build, and bowser/bundled adds babel polyfills on top
- Your traffic includes browsers newer than your lockfile: this is a regex-and-lookup-table project, so an unrecognized browser or OS comes back with undefined fields until someone lands a PR and a release ships
Setup reality
npm install bowser gets you a dependency-free UMD build with bundled TypeScript definitions, so it works from CommonJS, AMD, and ESM without configuration. Two things surprise people. First, the default entry is the ES5 transpiled build with no polyfills included, so very old targets need require('bowser/bundled'), which bundles babel-polyfill and is considerably larger. Second, the default TypeScript export is the class itself, so import Bowser from 'bowser' needs esModuleInterop enabled and import * as Bowser from 'bowser' otherwise. After that, plan for the data problem rather than the API: getParser throws if you hand it something that is not a string, which matters when a request arrives with no User-Agent header at all, and every field can be undefined for a UA the table does not know.
Patterns
Get everything in one objectparse-user-agent
import Bowser from 'bowser';
const result = Bowser.parse(window.navigator.userAgent);
// {
// browser: {name: 'Chrome', version: '131.0.0.0'},
// os: {name: 'macOS', version: '10.15.7', versionName: 'Catalina'},
// platform: {type: 'desktop'},
// engine: {name: 'Blink', version: '131.0.0.0'}
// }Every leaf can be undefined for a UA the tables do not recognize, so read with optional chaining. Bowser.parse throws if the argument is not a string, which a missing User-Agent header will give you server-side.
Ask for one field at a timeread-single-fields
const browser = Bowser.getParser(window.navigator.userAgent);
browser.getBrowserName(); // 'Safari'
browser.getBrowserName(true); // 'safari' (lowercased)
browser.getBrowserVersion(); // '17.4'
browser.getOSName(true); // 'macos'
browser.getPlatformType(); // 'desktop'
browser.getEngineName(); // 'WebKit'Passing true lowercases the returned name, which is what you want before comparing against your own constants. getParser keeps the parsed result cached, so reuse one parser instead of calling Bowser.parse repeatedly.
Check version ranges declarativelysatisfies-version-ranges
const browser = Bowser.getParser(window.navigator.userAgent);
const supported = browser.satisfies({
macos: {safari: '>=15'},
mobile: {safari: '>=15', 'android browser': '>3.10'},
chrome: '>=110',
firefox: '>=115',
opera: '>=95',
});OS and platform blocks win over the flat browser rules, so a macos entry overrides a top-level safari entry. Operators are >, >=, <, <=, = for an exact build, and ~ for loose matching, where '~20.1' matches any 20.1.x.
Treat unknown browsers as supported, not blockedhandle-undefined-result
const verdict = browser.satisfies(supportedBrowsers);
// undefined means "not mentioned in the tree", not "too old"
if (verdict === false) {
showUpgradeBanner();
}This is the bug everyone ships once. if (!verdict) treats undefined as a failure and shows the upgrade banner to every browser that came out after you wrote the list.
Improve accuracy with User-Agent Client Hintsclient-hints
const browser = Bowser.getParser(
window.navigator.userAgent,
window.navigator.userAgentData,
);
browser.getHints(); // the ClientHints object, or null
browser.hasBrand('Google Chrome'); // true
browser.getBrandVersion('Google Chrome'); // '131'navigator.userAgentData is Chromium-only and undefined in Safari and Firefox, so this has to be a progressive enhancement over UA parsing, not a replacement. High-entropy values such as platformVersion need a separate async getHighEntropyValues() call before you pass them in.
Parse a request's User-Agent in Nodeserver-side-parsing
import Bowser from 'bowser';
app.use((req, res, next) => {
const ua = req.headers['user-agent'];
req.client = ua ? Bowser.parse(ua) : null; // getParser throws on undefined
next();
});Guard the header: bots and health checks often send no User-Agent, and getParser throws 'UserAgent should be a string'. Parsing on every request is cheap but not free, so cache by UA string if you serve a lot of repeat traffic.
Test a single browser, OS, platform, or engineboolean-checks
browser.isBrowser('Chrome'); // exact name
browser.isBrowser('chrome', true); // allow short aliases
browser.isOS('Windows');
browser.isPlatform('mobile');
browser.isEngine('Blink');
browser.is('macOS'); // any of the above
browser.some(['Chrome', 'Firefox']);is() searches browser, OS, platform, and engine in turn, so a value that collides across categories can match something you did not mean. The explicit isBrowser/isOS/isPlatform/isEngine forms are worth the extra characters.
Compare against the detected version directlycompare-version
const browser = Bowser.getParser(navigator.userAgent);
if (browser.isBrowser('Safari') && browser.compareVersion('<16')) {
loadPolyfills();
}compareVersion applies to whatever browser was detected, so always pair it with an isBrowser check or you are comparing Safari rules against a Firefox version number.
Filter crawlers out of analyticsdetect-bots
const {platform, browser} = Bowser.parse(userAgent);
if (platform.type === 'bot') {
return; // skip recording this hit
}bot became a platform type in 2.12.0 and 2.13.0 and 2.14.0 added more crawlers, including AI crawlers, Slack, and Line. Well-behaved bots identify themselves; anything scraping you on purpose will not, so this thins the noise rather than removing it.
Compare against the exported name constantsuse-constant-maps
import Bowser from 'bowser';
console.log(Bowser.BROWSER_MAP.chrome); // 'Chrome'
console.log(Bowser.OS_MAP.MacOS); // 'macOS'
console.log(Bowser.PLATFORMS_MAP.tablet); // 'tablet'
console.log(Bowser.ENGINE_MAP.Blink); // 'Blink'Display names are capitalized inconsistently across categories ('macOS', 'Chrome', 'desktop'), so comparing string literals is fragile. Use these maps, or lowercase both sides.
Create a parser without parsing yetskip-parsing
const parser = Bowser.getParser(ua, true); // skipParsing
// later, only if you actually need it:
const name = parser.getBrowserName();The second positional argument is overloaded: a boolean means skipParsing, an object means Client Hints. Passing true and a hints object needs the three-argument form getParser(ua, false, hints).
Import it correctly from TypeScript and CommonJSimport-in-typescript
const Bowser = require('bowser'); // CommonJS
import * as Bowser from 'bowser'; // TypeScript without esModuleInterop
import Bowser from 'bowser'; // ESM, or TS with esModuleInterop
// polyfilled build for very old targets:
const Bowser = require('bowser/bundled');The default export is declared with export = style typings, which is why the plain default import needs esModuleInterop. The bundled entry ships babel-polyfill inside it, so only reach for it when you actually target pre-ES5 runtimes.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ua-parser-js | npm | You need device vendor and model data as well, and your project can live with AGPL-3.0-or-later on version 2 or a paid license. |
| detect-browser | npm | You want the smallest possible answer to "which browser and version" and do not need OS, engine, or range checks. |
| bowser-jr | npm | You want bowser's model but as separate importable parsers so a bundler can drop the ones you never call. |