Not Everything Can Be Feature Detected

In the early days of Web browsers, it was extremely common to see user agent checks in JavaScript. Sometimes, you'd write the same code in two or three different ways to support various browsers. Code such as that shown below allowed webpages to work in browsers like Netscape Navigator and Internet Explorer, who didn't always agree on how features should be implemented.

if (navigator.userAgent.indexOf('MSIE') > -1) {
  // code for Internet Explorer
} else {
  // code for other browsers

To exacerbate this, Internet Explorer introduced a syntax for conditional comments that allowed developers to provide IE-specific code. It even allowed devs to target specific versions of Internet Explorer.

<!--[if gte IE 6 ]>
  <p>Only IE 6 and above will see this</p>

This may seem like madness in 2022, but it was an absolute necessity to make your web app work everywhere. Some of you might remember those little This site best viewed in Internet Explorer badges.

Eventually, and probably largely due to Modernizr, we started telling developers to detect features instead of browsers. The idea was that, as standards became more established and browser development picked up after the IE6 lull, it was more reliable to test for specific features rather than specific browser versions.

These days, feature detection is the de facto winner over browser sniffing, which is mostly a good thing. Alas, some developers take things from one extreme to another. Over the years, I've stumbled upon numerous code snippets where, instead of detecting a feature, the developer was detecting something completely unrelated to the feature and using that as a flag to sniff the browser.

The line of thinking goes like this: "Feature A only works in Firefox, but I can't detect Feature A directly. Hmm...but I can detect Feature B which also only works in Firefox, so I'll use that to determine whether Feature A is supported!" (I'm intentionally omitting links to avoid calling anyone out.)

Hopefully, you can see why this is a bad idea. One day, Firefox might implement Feature A before Feature B, which means your test is now useless. Or, the opposite could happen and they ship Feature B before Feature A, leaving you with a broken website.

Alas, feature detection isn't always straightforward, so occasionally developers do things like this because it seems better than user agent sniffing. Which brings me to my next point...

Not Everything Can Be Feature Detected #

You see, not everything can be feature detected. One example can be found in the Shoelace code for <sl-input>. This component acts as a supercharged <input> element, offering more features than a standard form control. One of its features, for example, is a clear button. Many browsers will show their own clear button for <input type="search">, but not for other input types. Shoelace, on the other hand, allows you to add a custom clear button to any input type while providing a consistent UI in all browsers.

The problem is that Firefox adds its own clear button to date inputs, which resulted in this:

A Shoelace date input shown with two clear buttons, one custom and one by the browser.

No other browsers do this, and there's no obvious way to turn it off or hide it in Firefox. Furthermore, you can't feature detect it — or at least we haven't discovered a way to do so.

This is where some developers might lean on detecting Feature B to support Feature A to avoid browser sniffing — but we've already discussed why that's a bad idea.

So what's a developer to do?

Calling Out User Agent Checks #

The solution here is to do the thing that's become taboo...we need to sniff the user agent string for Firefox! That can be done with ease:

const isFirefox = navigator.userAgent.includes('Firefox');

But developers aren't the only ones calling out browser sniffing. Running this code in Chrome will yield a notice in the dev console.

A page or script is accessing at least one of navigator.userAgent, navigator.appVersion, and navigator.platform. Starting in Chrome 101, the amount of information available in the User Agent string will be reduced.

To fix this issue, replace the usage of navigator.userAgent, navigator.appVersion, and navigator.platform with feature detection, progressive enhancement, or migrate to navigator.userAgentData.

While sniffing is usually a bad idea, Chrome isn't exactly saying that here. It's letting us know that they're going to be reducing the information provided in navigator.userAgent, likely to mitigate fingerprinting. This check will probably continue to work for a long time, but if we want to eliminate this notice from the console, we need to use an alternative. That's where navigator.userAgentData comes in.

Currently, support is limited to Chromium-based browsers, but we can still leverage it — perhaps ironically — using feature detection!

To suppress the old school user agent check, we can look at navigator.userAgentData first. If it's present, we can check to see if we're using a Chromium browser and, if so, stop there. Otherwise, we can do the traditional check.

const isChromium = navigator.userAgentData?.brands.some(b => b.brand.includes('Chromium'));
const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');

Note that we're specifically checking for Chromium in navigator.userAgentData.brands because that's a known value. It's not clear if or when Firefox will support navigator.userAgentData and, when does, what it will provide as a brand. Checking for "Firefox" seems sensible, but given the strange history of user agent strings, they could ship something like "Gecko" or "Quantum" instead.

Or, devastatingly, they could ship "Chromium" for some kind of compatibility reason. If they do that, well, this check will blow up.*

So yeah, turns out browser sniffing really does stink.

Browser Sniffing Stinks, But It's Sometimes Necessary #

Clearly, user agent sniffing is unreliable, so there's good reason to avoid it. But if we commit to never ever doing it, there will inevitably be some problems we can't solve.

So when is it OK to do naughty things? Typically as a last resort after all other options have been exhausted. In the Shoelace example above, no functionality is lost if the check fails. Instead, the user will simply see two clear buttons. That's not ideal, but it's also not broken.

That's a good rule of thumb. If you absolutely can't feature detect something and you can implement a sniff that, when it fails, won't lead to a loss of functionality, go ahead and sniff that browser.

*In this case, it seems unlikely Firefox would bloat the new navigator.userAgentData API with the wrong user agent. After all, the new API doesn't have all the crazy history that navigator.userAgent has, but we know better than to make such assumptions on the Web.