How to Parse User Agents Reliably

2026-03-27 · SPUNK13 · spunk.bet

The string you are parsing is deliberately dishonest

Every mainstream browser sends a user agent that begins Mozilla/5.0, which stopped being true in the 1990s. Chrome claims to be Safari, Safari claims to be KHTML, Edge claims to be Chrome. Each generation copied the previous one's token to get past servers that sniffed for it.

The string is also frozen in Chromium: the minor, build and patch numbers report as 0.0.0, the platform version stops updating, and Android device models collapse to a generic value for most users. A parser can still say "Chrome on Windows", but it can no longer separate Windows 10 from Windows 11, or tell you which phone you are looking at. Any dashboard segment built on those fields went quietly wrong some time ago.

Client Hints are the supported replacement, and they are opt-in

Chromium browsers send three low-entropy headers on every request without being asked: Sec-CH-UA (brand list plus major version), Sec-CH-UA-Mobile and Sec-CH-UA-Platform. Everything else — full version list, platform version, architecture, device model — requires you to ask for it on a previous response:

Accept-CH: Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Arch
Critical-CH: Sec-CH-UA-Platform-Version
Vary: Sec-CH-UA-Platform-Version

Three details bite people. The hint does not arrive on the request that carried Accept-CH, only on subsequent ones — Critical-CH asks the browser to retry immediately so you get it on the first page. You must set Vary or your CDN will serve a desktop-shaped response to a phone. And Firefox and Safari do not send UA Client Hints at all, so the legacy string remains your only fallback for a meaningful slice of traffic.

Libraries worth using, and what each is for

Whichever you choose, the regex database ages badly. Pin the version, but schedule a refresh every quarter and treat a stale parser as a data bug: new browser releases fall through to "Other" and silently deflate your numbers.

The performance cost is real at request scale

A full parse walks a list of hundreds of regular expressions until one matches, and the popular browsers are not always near the front. On a busy API that is a per-request cost you are paying to populate an analytics field nobody reads on the hot path. Two fixes, in order of value:

  1. Do not parse on the request path at all. Log the raw UA string and parse it in your analytics pipeline, offline, where an extra millisecond costs nothing.
  2. Cache by hash. Real traffic has enormous repetition — the same few thousand distinct strings cover the vast majority of hits. Key an LRU on a hash of the UA string and cap it so a flood of randomised agents cannot grow it without bound.
const cache = new LRUCache({ max: 5000 });
function parseUA(ua = '') {
  const key = createHash('sha1').update(ua).digest('base64');
  let hit = cache.get(key);
  if (!hit) { hit = parser.setUA(ua).getResult(); cache.set(key, hit); }
  return hit;
}

Feature detection beats sniffing for anything that changes behaviour

If the parse result decides which code path runs, you have built a bug that surfaces on browsers that did not exist when you wrote it. Ask the platform instead: if ('share' in navigator), CSS.supports('height: 100dvh'), @supports blocks in stylesheets, and media queries such as (pointer: coarse) for touch. Reserve UA parsing for things it is genuinely good at: aggregate reporting, routing app-store download buttons to the right store, and matching a specific known-broken build while you wait for a fix. Write those workarounds with an expiry date in the comment, because the string you are matching will change and the fallback branch will rot unnoticed.

Explore More

Free tools, guides, and resources.

Visit spunk.bet
400+ ToolsCasinoMemesAstrologyScam DB