~/blog/useragent-parser|
Scan this vCard to save my contacts
blog post cover
#frontend

A curious approach to detect unsupported browsers

August 16, 2022 · 22 min read

Table of Contents

Consider doing it properly instead

One of the most controversial things frontend engineers may be asked about is to implement a user agent parsing on the client side. Technically, it's not hard to do, but it goes along with many circumstances that may lead to undesired behavior or even a broken app in the future.

Do you wanna be hacked? 😎 Yes/No

Let's consider some use cases of doing such a flaky thing on FE side:

  • Showing unsupported browsers notification
  • Redirecting unsupported browsers to another page
  • Toggling App Store/Google Play buttons
  • Detecting features support
  • Changing look and feel based on OS (iOS, Android)
  • Deciding whether Modern or Legacy app bundle should be used

What status code to use if server detected a browser as outdated and wants to terminate the incoming request disconnecting the client?

It appears that 403 Unauthorized is the most suitable one.

If I needed to perform a check against specific feature support in the browsers, I would rather go with the same technique usually polyfills use. What if my app required WebRTC support to function properly?

/**
 * Checks whether WebRTC is supported by user browser.
 */
function isWebRTCSupported() {
  return window.RTCPeerConnection && window.mediaDevices && window.mediaDevices.getUserMedia;
}

Or roughly the same technique but for visuals and CSS-related things, using CSS feature queries:

/* Turn on Grid layout if it's supported */
@supports (display: grid) {
  .root {
    display: grid;
  }
}

Or using JavaScript:

/**
 * Checks whether current browser has a CSS Grid support.
 */
function isCSSGridSupported() {
  return CSS && CSS.supports('display: grid');
}

Take a look at the example MDN suggests for detecting touch screen.

So, if I was asked to do any kind of browser detection on client side, I would first try to understand the root of the problem and do it properly. Web was designed to be equally accessible for everyone, thus let's tend to make it so. ✌️

User Agent strings

Another ancient technique is to parse a User Agent string, extract the browser name and its version and then decide what should we do next. Naming it "ancient" I'm not criticizing the approach, as sometimes this way is the only possible. For example, if we have to execute these checks on a server or inside a WebWorker where browser APIs are not present or the amount of available APIs is limited.

One of the most often required things is to notify a user using a floating notification if they use an outdated browser that is no longer supported by an app or website. It's relatively easy to implement if we own a server or use SSR, but at the same time really tricky if we don't.

What I saw many times people do is writing their own UA parsers or using one of the existing Open-Source tools that ship with predefined set of UA strings or regular expressions.

bowser vs ua-parser-js

They are not that heavy though, both packages are approximately 6kb gzipped:

And here we come to the 2 advices and would like to give to everyone who is stuck in the situation with UA strings parsing.

Advice #1: do not write your own parsers

Seriously, just don't. I confess, I was also involved in this. 😄

Regardless of good intentions of not brining third-party library into the project and simplicity of this method, it's risky.

They are hard to maintain, hard to keep up to date with upcoming browser releases and I doubt someone from FE team will thank you a year or a couple of years later when they will receive a task to do something with your handwritten regular expressions.

Take a look at your codebase, and put a tech-debt task into the backlog, if you see something like this:

/**
 * 🛑Candidate for refactoring
 */
const CHROME_MIN_VERSION = 61;
const isChrome = navigator.userAgent.match(/Chrome\/(\d+)/) ?? false;

if (isChrome && isChrome[1] && parseInt(isChrome[1]) < CHROME_MIN_VERSION) {
  // do something
  showUnsupportedBrowserNotification();
  disableStickyNavigation();
}

Even community-driven libraries may suffer from little unexpected bugs in version parsing. Lately, when Chrome reached 3-digits version (100) I was checking all the proprietary UA parsers we had in our project to ensure nothing got broken. So were doing library maintainers.

No one business expects its users coming from a fresh browser to be redirected somewhere or shown an annoying alert saying their browser is not up-to-date. At least make sure to cover such a code with conscious, well-written tests.

Advice #2: use Browserslist to stay in sync

So far, we have 2 main issues with all the approaches described above:

  • Maintenance of self-written parsers is complicated
  • Even using open-source tools, how to keep it up to date with the real list of supported browsers?

It may not be clear what I mean under the second point, so I could elaborate a bit on this.

Usually in the modern Frontend development we have to ship our JS code transpiled or even compiled (from TS). And there's a couple of amazing tools that help us with this uneasy business: Babel and PostCSS.

Babel and its plugins are responsible for JavaScript1 and TypeScript2 transformations, as well as PostCSS handles CSS stylesheets adding all the required prefixes or polyfilling3 not-yet-supported CSS properties and expressions. But you know this, of course.

There's a third tool in the middle to help Babel and PostCSS understand what features should be transpiled or polyfilled and what shouldn't: Browserslist.

Browserslist uses caiuse database to vendor version and features it supports. You should have seen it many times in the .browserslistrc or package.json file of almost every frontend project. For instance, this is the list of browsers supported by this website at the moment when this article is being written:

[production]
>0.3%
not ie 11
not dead
not op_mini all

If you wonder what does having such a config mean on practice, we can use a browserslist CLI utility to convert it to the list of real browsers.

Let's execute npx browserslist:

➜ npx browserslist
and_chr 104
and_ff 101
and_uc 12.12
android 4.4.3-4.4.4
chrome 103
chrome 102
edge 103
firefox 102
ios_saf 15.5
ios_saf 15.4
ios_saf 15.2-15.3
ios_saf 15.0-15.1
ios_saf 14.5-14.8
ios_saf 14.0-14.4
ios_saf 12.2-12.5
opera 88
safari 15.5
safari 14.1
samsung 17.0

Now, when we have a list of browsers, it's time to convert it to a real regular expression. It could be done manually, but instead, let's use this fascinating browserslist-useragent-regexp package.

A utility to compile browserslist query to a RegExp to test browser useragent. The Simplest example: you can detect supported browsers on client-side.

browserslist-useragent-regexp README.md

Sounds exactly what we need, isn't it? Let's install the package and write a simple generator script.

yarn add --dev browserslist-useragent-regexp

The package API is simple and consists of 2 functions:

  • getUserAgentRegExp compile browserslist query to one RegExp.
  • getUserAgentRegExps compile browserslist query to RegExps for each browser

Using these APIs, we can generate a RegExp and write it to a file somewhere in the project tree, to import it and use from any place we want. Time to write some code:

import fs from 'node:fs/promises';
import path from 'node:path';
import prettier from 'prettier';
import { getUserAgentRegExp } from 'browserslist-useragent-regexp';

const destination = path.resolve('./src/shared/lib/supportedBrowsers.ts');
const regExp = getUserAgentRegExp({ allowHigherVersions: true });

const template = `
/*
 * ---------------------------------------------------------
 * ## THIS FILE WAS GENERATED VIA 'browserslist:generate' ##
 * ##                                                     ##
 * ## DO NOT EDIT IT MANUALLY                             ##
 * ---------------------------------------------------------
 */

 export const supportedBrowsersRegExp =
 ${regExp};
`;

const prettierConfig = await prettier.resolveConfig(destination);
const content = prettier.format(template, { ...prettierConfig, parser: 'babel' });
await fs.writeFile(destination, content);

That's it!

allowHigherVersions: true ensures generated RegExp includes all the future browser versions, not only the exact same range we have at the moment. Don't be confused with these extra prettier things I've added into the script to reformat the generated file properly.

Add the command to the package.json scripts section and try running it:

yarn browserslist:generate
/*
 * ---------------------------------------------------------
 * ## THIS FILE WAS GENERATED VIA 'browserslist:generate' ##
 * ##                                                     ##
 * ## DO NOT EDIT IT MANUALLY                             ##
 * ---------------------------------------------------------
 */

export const supportedBrowsersRegExp =
  /((CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS)[ +]+(12[_.]2|12[_.]([3-9]|\d{2,})|12[_.]5|12[_.]([6-9]|\d{2,})|(1[3-9]|[2-9]\d|\d{3,})[_.]\d+|14[_.]0|14[_.]([1-9]|\d{2,})|14[_.]4|14[_.]([5-9]|\d{2,})|14[_.]8|14[_.](9|\d{2,})|(1[5-9]|[2-9]\d|\d{3,})[_.]\d+|15[_.]0|15[_.]([1-9]|\d{2,})|(1[6-9]|[2-9]\d|\d{3,})[_.]\d+)(?:[_.]\d+)?)|((?:Chrome).*OPR\/(88\.0|88\.([1-9]|\d{2,})|(89|9\d|\d{3,})\.\d+)\.\d+)|(SamsungBrowser\/(17\.0|17\.([1-9]|\d{2,})|(1[8-9]|[2-9]\d|\d{3,})\.\d+))|(Edge\/(103(?:\.0)?|103(?:\.([1-9]|\d{2,}))?|(10[4-9]|1[1-9]\d|[2-9]\d\d|\d{4,})(?:\.\d+)?))|((Chromium|Chrome)\/(102\.0|102\.([1-9]|\d{2,})|(10[3-9]|1[1-9]\d|[2-9]\d\d|\d{4,})\.\d+)(?:\.\d+)?)|(Version\/(14\.1|14\.([2-9]|\d{2,})|(1[5-9]|[2-9]\d|\d{3,})\.\d+|15\.5|15\.([6-9]|\d{2,})|(1[6-9]|[2-9]\d|\d{3,})\.\d+)(?:\.\d+)? Safari\/)|(Firefox\/(101\.0|101\.([1-9]|\d{2,})|(10[2-9]|1[1-9]\d|[2-9]\d\d|\d{4,})\.\d+)\.\d+)|(Firefox\/(101\.0|101\.([1-9]|\d{2,})|(10[2-9]|1[1-9]\d|[2-9]\d\d|\d{4,})\.\d+)(pre|[ab]\d+[a-z]*)?)/;

More on Browserslist queries

This RegExp is not very accurate though for use-cases like showing a notification about an outdated browser, because of rules like >0.3%. This rule adds browsers with total amount of users >0.3%. At this time, saying about Google Chrome, this will result in the following versions:

$ npx browserslist-useragent-regexp --verbose

Family:                           chrome
Versions:                         102.0.0 103.0.0 104.0.0
Source RegExp:                    /(Chromium|Chrome)\/(\d+)\.(\d+)(?:\.(\d+))?/
Source RegExp fixed version:      ...
Source RegExp browsers versions:  ... - ...
Versioned RegExp:                 /(Chromium|Chrome)\/10[2-4]\.0(?:\.\d+)?/

As you can see, only chrome with versions 102 and above is present in the resulting RegExp. Thus make sure to tune browserslist queries according to your needs.

For example:

  • chrome >= 87 or
  • last 10 chrome versions

For quick test let's use a BROWSERSLIST environment variable to override the query:

$ BROWSERSLIST="chrome >= 87" npx browserslist-useragent-regexp --verbose

> Browserslist

chrome  87.0.0  88.0.0  89.0.0  90.0.0  91.0.0  92.0.0  93.0.0  94.0.0  95.0.0  96.0.0  97.0.0  98.0.0  99.0.0  100.0.0  101.0.0  102.0.0  103.0.0  104.0.0


> RegExps

Family:                           chrome
Versions:                         87.0.0 88.0.0 89.0.0 90.0.0 91.0.0 92.0.0 93.0.0 94.0.0 95.0.0 96.0.0 97.0.0 98.0.0 99.0.0 100.0.0 101.0.0 102.0.0 103.0.0 104.0.0
Source RegExp:                    /(Chromium|Chrome)\/(\d+)\.(\d+)(?:\.(\d+))?/
Source RegExp fixed version:      ...
Source RegExp browsers versions:  ... - ...
Versioned RegExp:                 /(Chromium|Chrome)\/(8[7-9]|9\d|10[0-4])\.0(?:\.\d+)?/


/((Chromium|Chrome)\/(8[7-9]|9\d|10[0-4])\.0(?:\.\d+)?)/

If it's needed, additional browser groups in .browserslistrc file could be created:

[production]
>0.3%
not dead
not op_mini all

[development]
last 1 chrome version

[notification]
dead
edge < 16
firefox < 60
chrome < 61
safari < 12
opera < 48
ios_saf < 11.4
and_chr < 71
and_ff < 64

Then pass a name of the list using BROWSERSLIST_ENV variable:

BROWSERSLIST_ENV=notification yarn browserslist:generate

/((CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS)[ +]+(3[_.]2|4[_.](0|1|2|3)|5[_.](0|1)|6[_.](0|1)|7[_.](0|1)|8[_.](0|1)|8[_.]4|9[_.]0|9[_.](2|3)|10[_.]0|10[_.](2|3)|11[_.]0|11[_.]2)(?:[_.]\d+)?)|(CFNetwork\/672\.1\.15)|(CFNetwork\/709\.1)|(CFNetwork\/711\.(\d))|(CFNetwork\/758\.(\d))|(CFNetwork\/808\.(\d))|(CFNetwork\/.* Darwin\/10\.\d+)|(CFNetwork\/.* Darwin\/11\.\d+)|(CFNetwork\/.* Darwin\/13\.\d+)|(CFNetwork\/6.* Darwin\/14\.\d+)|(CFNetwork\/7.* Darwin\/14\.\d+)|(CFNetwork\/7.* Darwin\/15\.\d+)|(CFNetwork\/8.* Darwin\/16\.5\.\d+)|(CFNetwork\/8.* Darwin\/16\.6\.\d+)|(CFNetwork\/8.* Darwin\/16\.7\.\d+)|(CFNetwork\/8.* Darwin\/16\.\d+)|(CFNetwork\/8.* Darwin\/17\.0\.\d+)|(CFNetwork\/8.* Darwin\/17\.2\.\d+)|(CFNetwork\/8.* Darwin\/17\.3\.\d+)|(CFNetwork\/8.* Darwin\/17\.\d+)|(Fennec\/((2|3)\.0|3\.(5|6)|([4-9]|1\d|[2-4]\d|5\d)\.0)\.?([ab]?\d+[a-z]*))|(Fennec\/((2|3)\.0|3\.(5|6)|([4-9]|1\d|[2-4]\d|5\d)\.0)pre)|(Fennec\/((2|3)\.0|3\.(5|6)|([4-9]|1\d|[2-4]\d|5\d)\.0))|((Namoroka|Shiretoko|Minefield)\/((2|3)\.0|3\.(5|6)|([4-9]|1\d|[2-4]\d|5\d)\.0)\.(\d+(?:pre)?))|((Namoroka|Shiretoko|Minefield)\/((2|3)\.0|3\.(5|6)|([4-9]|1\d|[2-4]\d|5\d)\.0)([ab]\d+[a-z]*)?)|(Opera\/.+Opera Mobi.+Version\/((10|11)\.0|11\.1|11\.5|12\.(0|1)))|(Opera\/((10|11)\.0|11\.1|11\.5|12\.(0|1)).+Opera Mobi)|(Opera Mobi.+Opera(?:\/|\s+)((10|11)\.0|11\.1|11\.5|12\.(0|1)))|(Opera\/9.80.*Version\/(9\.0|9\.(5|6)|10\.(0|1)|10\.(5|6)|11\.(0|1)|11\.(5|6)|12\.(0|1)|(1[5-9]|[2-3]\d|4[0-7])\.0)(?:\.\d+)?)|((?:Chrome).*OPR\/(9\.0|9\.(5|6)|10\.(0|1)|10\.(5|6)|11\.(0|1)|11\.(5|6)|12\.(0|1)|(1[5-9]|[2-3]\d|4[0-7])\.0)\.\d+)|(SamsungBrowser\/4\.0)|(CrMo\/([4-9]|1\d|[2-5]\d|60)\.0\.\d+\.\d+([\d.]+$|.*Safari\/(?![\d.]+ Edge\/[\d.]+$)))|(Edge\/1[2-5](?:\.0)?)|((Chromium|Chrome)\/([4-9]|1\d|[2-5]\d|60)\.0(?:\.\d+)?([\d.]+$|.*Safari\/(?![\d.]+ Edge\/[\d.]+$)))|(IEMobile[ /](10|11)\.0)|(PlayBook.+RIM Tablet OS (7\.0|10\.0)\.\d+)|((Black[bB]erry|BB10).+Version\/(7\.0|10\.0)\.\d+)|(Version\/(3\.(1|2)|(4|5)\.0|5\.1|6\.(0|1)|7\.(0|1)|(8|9)\.0|9\.1|10\.(0|1)|11\.(0|1))(?:\.\d+)? Safari\/)|(Trident\/7\.0)|(Trident\/6\.0)|(Trident\/5\.0)|(Trident\/4\.0)|(Firefox\/((2|3)\.0|3\.(5|6)|([4-9]|1\d|[2-4]\d|5\d)\.0)\.\d+)|(Firefox\/((2|3)\.0|3\.(5|6)|([4-9]|1\d|[2-4]\d|5\d)\.0)(pre|[ab]\d+[a-z]*)?)|(([MS]?IE) (5\.5|([6-9]|1[0-1])\.0))/

It works fine, but there might be issues in the future, so consider the risks. I see this approach of handling particular browsers viable for apps where it's either crucial to force users to use only the latest software (due to security reasons for example), or when we may allow ourselves to support only X latest versions of some particular browser.

But I would like to try it in practice, perhaps combining regular expressions with classic feature-detection technique.

Other interesting things to consider

Footnotes

  1. https://babeljs.io/docs/en/babel-preset-env

  2. https://babeljs.io/docs/en/babel-preset-typescript

  3. https://preset-env.cssdb.org/