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.
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.
device.type.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.
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:
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;
}
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.