The problems with feature detection

By now everybody should already know this. You should not rely on browser detection. User-agent sniffing is evil. Use feature detection instead. Sound and solid advice. At least until you start looking at some of the more unusual browsers.

Earlier this summer I did extensive research on smart TV and console browsers. It showed me that these browsers are a lot like mobile browsers 10 years ago — before Chrome and Safari. Everybody is trying, but nobody really knows what is right. More on that at a later time.

One important lesson I learned was that we as developers make a lot of assumptions.

What is feature detection?

The principle of feature detection is really simple. Before you use a particular API you test if it is actually available. If it is not, you can provide an alternative or fail gracefully. Why is this necessary? Well, unlike HTML and CSS, JavaScript can be very unforgiving. If you would use an API without actually testing for its existence and assume it just works you risk that your script will simply throw an error and die when it tries to call the API.

Take the following example:

if (navigator.geolocation) 
{
    navigator.geolocation.getCurrentPosition(function(pos) {
        alert(
            'You are at: ' + 
            pos.coords.latitude + ', ' + pos.coords.longitude
        );
    });
}

Before we call the getCurrentPosition() function, we actually check if the Geolocation API is available. This is a pattern we see again and again with feature detection.

If you look carefully you will notice that we don’t actually test if the getCurrentPosition() function is available. We assume it is, because navigator.geolocation exists. But is there actually a guarantee? No.

if (navigator.geolocation && 
    navigator.geolocation.getCurrentPosition) 
{
    navigator.geolocation.getCurrentPosition(function(pos) {
        alert(
            'You are at: ' + 
            pos.coords.latitude + ', ' + pos.coords.longitude
        );
    });
}

I do not know of any browser that does support Geolocation, but not the getCurrentPosition() function. But that is not the case for all APIs. Adding a test for getCurrentPosition() would be safer. It takes away one more assumption. But in reality the first example would be fine in most cases.

Cutting the mustard

There is another principle that has gotten very popular lately. By using some very specific feature tests you can make a distinction between old legacy browsers and modern browsers.

if ('querySelector' in document
    && 'localStorage' in window
    && 'addEventListener' in window) 
{
    // bootstrap the javascript application
}

In itself it is a perfectly valid way make sure the browser has a certain level of standards support. But at the same time also dangerous, because supporting querySelector, localStorage and addEventListener doesn’t say anything about supporting other standards.

Even if the browser passes the test, you really still need to do proper feature detection for each and every API you are depending on.

The underlying premise

We’ve talked about two kinds of assumptions so far. The assumption that if an API is available it is available in its entirety and the assumption that because one feature is supported, other features are too. But we’ve skipped over the most important assumption. We assume that because an API is available, it actually works. And I’m not talking about a bug here or there. This is the whole underlying premise of feature detection. But this too is a very big assumption.

Take a look at the following example:

if ('localStorage' in window) {
    window.localStorage.setItem('key', 'value');
}

In the example above we test that the Local Storage API is actually available and then we use it. What can go wrong here? Like I said before, we are making the assumption that because the API is available, we can actually use it. And in this case it is a very dangerous assumption. If your users use iOS and browse with ‘Private mode’ enabled, the browser will refuse to store information. In fact, it wont just forget the information after the current session, instead it will throw an exception because your script was denied access to the storage.

So if you want to properly detect the Local Storage API you’ll need to take an additional step:

var supported = 'localStorage' in window;
if (supported) {
    try {
        window.localStorage.setItem('incognito', 'false');
        window.localStorage.removeItem('incognito');
    } catch(e) {
        supported = false;
    }
}
if (supported) {
    window.localStorage.setItem('key', 'value');    
}

We can detect the Local Storage API by actually trying to use it and see if it fails. But that won’t work for some other features.

Undetectable features

There are features where the whole premise of feature detection just fails horribly. Some browsers ship features that are so broken that they do not work at all. Sometimes it is a bug, and sometimes it is just pure laziness or incompetence. That may sound harsh, but I’m sure you agree with me at the end of this article.
The most benign variants are simply bugs. Everybody ships bugs. And the good browsers quickly fix them. Take for example Opera 18 which did have the API for Web Notifications, but crashed when you tried to use it. Blink, the rendering engine, actually supported the API, but Opera did not have a proper back-end implementation. And unfortunately this feature got enabled by mistake. I reported it and it was fixed in Opera 19. These things happen.

Editing HTML

A somewhat more serious example is contentEditable. While it is possible to do feature detection for this attribute and the related execCommmand API’s, this does not tell you anything about whether or not this feature is actually usable. On desktop browsers it will probably work as intended, but especially television, tablet and mobile browsers used to be problematic. The browsers on Android 2.3 and iOS 4 used to pass the feature detection and actually had fully functional implementations. But they were still unusable because they did not pop up an on-screen keyboard. The browser acted just like its desktop equivalent, showed a blinking text cursor and waited for input that of course never came.

Uploading files

Very similar is the ability to upload or access files using the <input type=”file” /> form field. Support for this form field can be tested like this:

var field = document.createElement('input');
field.type = 'file';
var supported = field.type == 'file';
if (supported) {
    // Enable upload functionality
}

And if you want to know if the browser can access the selected file directly you can extend this test by looking at the files property. And just to be sure, you may also want to check if the FileReader API is available to actually read the file:

var supported = field.type == 'file' && 
                field.files && field.files instanceof FileList &&
                "FileReader" in window = true;

But all of this is moot, if the button that allows you to pick a file is not functional. This was a real problem in iOS 5 and Windows Phone 8.0. And it actually still is a problem on the Xbox One, Playstation 4, WebOS televisions and others. If you try to select a file on one of these devices a message will pop up which tells you that this feature is not supported.

The Playstation 4 can not upload files

And that is just wrong. It goes against all that is feature detection. If a feature is not supported, it should not be detectable. That is how feature detection should work. It allows the website to fail gracefully and provide an alternative. Or simply as a web developer make the decision to make this not visible to the user at all. Allowing to user to click the upload button and showing an error message after the fact is the worst thing you could do.

Accessing the webcam

During my research on television and console browsers I found many, many more examples of features that were simply not functional. Take for example the getUserMedia API which allows to you access the camera or webcam. On a LG WebOS television from 2014 this API is enabled and LG actually sells an official webcam. The webcam works perfectly with the Skype app, but the browser does not seem to recognize it.

if (navigator.getUserMedia) {
    function success(stream) {
        // show the video
    }
    function failure() {
        // no webcam found
    }
    navigator.getUserMedia(
      { audio: true, video: true }, success, failure
    );
}

What happens in the example above is the feature test is successful, but the failure callback is called immediately. While technically correct, it still goes against the whole idea of feature detection. There is only one supported webcam sold by LG and they know it does not work. So why not simply disable this API completely.

And that is actually what LG did in its 2015 series of WebOS televisions. They disabled a number of APIs that are not actually supported. That is a big step forward and I wish more vendors followed their footsteps.

The Gamepad API

If you want to make an HTML based game that can run on a console browser, the Gamepad API is particularly of interest. And on first glance it looks like it is supported on most consoles: the Xbox One, the Playstation 4 and the Wii U. However when we take a closer look the situation becomes very similar to accessing the webcam. The API is there, but that does not mean it will actually work. Only the Xbox One actually reports back any connected gamepads when you use this API. The Playstation 4 and the Wii U simply give back an empty array.

if (navigator.getGamepads) {
    var gamepads = navigator.getGamepads();
}

An array that is always empty means that we can’t tell that the browser does not support this feature or if the user simply needs to connect a gamepad. Imagine the confusion with users who are told to connect their gamepad when it is already connected.

The Nintendo Wii U browser can’t tell, but there were in fact three different gamepads connected…

The Wii U did not support this feature in earlier versions of the browser. Nintendo uses its own proprietary API and that does work without any problems. But apparently during an update the standard Gamepad API accidentally got enabled.

Defensive coding required

The reality is that we can’t trust simple feature detection anymore. We need to take into account that all features are probably broken in some version of some browser. We need to stop making assumptions and make our code resilient to broken APIs.

The single feature that caused me the most headaches during my television and console browser research was Geolocation. At first I was pleasantly surprised that many browsers had this feature enabled. But that happiness did not last long.

Now most smart TV’s do not have GPS chips. But that does not need to be a problem. You can do basic geolocation based on Wi-Fi. You could even fall back to IP based locations. And if you don’t support it at all, you could simply disable the feature altogether. That is what about half of them actually do. They disable it.

The other half have this feature enabled. The problem was that none of the browsers that did support the API actually worked. None. All of them failed in one way or another.

Take a look at the example below and assume for a moment that feature detecting passed and that we are safe to actually get our location using the getCurrentPosition() function.

function success(pos) {
    // use location
}
function failure(err) {
    // failure, show alternative way to select location
}
navigator.geolocation.getCurrentPosition(success, failure);

This piece of code is very simple and straightforward. But it will actually fail in 4 different ways. Yes. Four different ways.

We assume that either the success or failure callback will be called by the getCurrentPosition() function. Well, that assumption is wrong. On four independent browsers neither of the callbacks are called. That means the user just keeps waiting for a location to appear. And it never does. This is a big problem and needs to be dealt with. I’ve noticed this problem on an LG WebOS television from 2014, a Sony Blu-ray player running Opera Devices and Philips Android TV from 2014 running the regular version of Chrome. The Xbox One also used to suffer from this issue but they quickly fixed it after I reported it back in 2014. The 2015 series of LG WebOS television also fixed it. And by fixing I mean they completely disabled the feature.

The second way defeats feature detection, but will actually not cause many problems. The getCurrentPosition() function simply calls the failure callback. Some say that the location can not be found, others tell that the user denied permission. I’ve noticed this problem on Xbox 360, the PlayStation TV, a Panasonic Firefox OS television and a Samsung television from 2014.

Some browsers that don’t support this feature will actually call the success callback. They don’t give any useful information though: the coordinates will be 0,0. Again this is something we want to check for because the user won’t understand why our site thinks his location is in the middle of the Atlantic Ocean. I’ve seen this problem on a Tizen based Samsung television from 2015.

But things get even weirder. There is one device that is almost comically broken: A Google TV set top box from Sony. Even though it runs Android, it actually has an old port of the desktop version of Chrome. And it calls the success callback with actual coordinates. Sounds good, right? But, no matter where you are, the coordinates are always the same: somewhere in Mountain View, California, close to Google’s headquarter.

The only users of Google TV are living in Mountain View, apparently.

All of these issues can be worked around. But this requires some defensive programming. The script below will actually override the standard getCurrentPosition() function with an improved one that checks for coordinates like 0,0 and the that one very specific place in Mountain View. It also adds a 10 second timeout, so that if neither callback is called by the browser, it will actually call the failure callback and simulate a timeout error.

(function() {
    if (navigator.geolocation) {
        function PositionError(code, message) {
            this.code = code;
            this.message = message;
        }
        PositionError.PERMISSION_DENIED = 1;
        PositionError.POSITION_UNAVAILABLE = 2;
        PositionError.TIMEOUT = 3;
        PositionError.prototype = new Error();
        navigator.geolocation._getCurrentPosition = navigator.geolocation.getCurrentPosition;
        
        navigator.geolocation.getCurrentPosition = function(success, failure, options) {
            var successHandler = function(position) {
                if ((position.coords.latitude == 0 && position.coords.longitude == 0) ||
                    (position.coords.latitude == 37.38600158691406 && position.coords.longitude == -122.08200073242188)) 
                    return failureHandler(new PositionError(PositionError.POSITION_UNAVAILABLE, 'Position unavailable')); 
                failureHandler = function() {};
                success(position);
            }
            var failureHandler = function(error) {
                failureHandler = function() {};
                failure(error);
            }
            
            navigator.geolocation._getCurrentPosition(successHandler, failureHandler, options);
            window.setTimeout(function() { failureHandler(new PositionError(PositionError.TIMEOUT, 'Timed out')) }, 10000);
        }
    }
})();

Why do we have this problem in the first place?

The reality is that most browser vendors don’t actually make their own rendering engine. In itself this is a good thing because picking a pre-existing rendering engine will ensure that websites will render properly without the browser vendor having to do any actual work. They can just focus on the user interface. Take a look at what goes wrong right now and imagine everything that can go wrong when do build their own rendering engine. So in one sense we’re lucky they only have to focus on the user interface.

And that is exactly why we have this problem. You can’t just focus on just the user interface. Nowadays many of the APIs that the rendering engine provides needs to talk to the operating system or hardware. And apparently many browser vendors forget to do that. Laziness… Incompetence? You tell me.

The result is that we have to deal with broken feature detection. Basically every feature that talks to the operating system or hardware is suspect.

Do you want to upload a file? The operating system needs to show a file picker. And if the browser vendor forgot to hook that into the rendering engine, it will simply not work. If you don’t provide a geolocation provider for the rendering engine, it will not work. If you don’t hook up the gamepad API to the USB or Bluetooth hardware… it will not work. If you don’t provide a way for the rendering engine to get video and audio from the webcam… Yeah.

Now I don’t mind that these features do not work. What I do mind is that the browser vendors did not disable them completely. Because right now they break feature detection and that is completely unnecessary.

It all comes down to attention to detail. You’d expect a browser vendor to notice when they ship an API that is not functional. But apparently that is too much to ask for some vendors. And it is not a problem limited to television and console browsers. I’ve seen these kinds of problems with just about any mobile browser that does not use its own rendering engine. Sometimes I even wonder if they deliberately enable unfinished features just to score higher on HTML5test.com. But like Hanlon’s razor says: Never attribute to malice that which is adequately explained by stupidity.

A way forward…

The sad news is that for current smart TV’s the problem will most likely not be fixed. Television browsers are usually not updated at all. We can only hope that others will follow LG’s example and simply disable the features they don’t actually support in the next year’s series.

And “hoping” is the best we can do in many cases, because many of these vendors don’t have public bug trackers or people doing developer relations for their web browser. Many of the bugs I have found remain unreported, because I simply do not know where to report them. What every browser vendor should do at a minimum is spend some resources on developer relations. Create a developer website that allows us to give feedback and then actually do respond to and act on that feedback.

I’m also doing my part by adding a number of these features to the blacklist on HTML5test.com, so vendors that break feature detection do not get any points for features they don’t actually support. That also means that if manufacturers were hesitant to ship a browser with a lower score, they can safely disable these features without having to fear that their HTML5test.com score will drop.

And maybe there is one thing that the creators of the rendering engines can do. Perhaps it would be possible to disable features that depend on hardware or the operating system by default. That way any browser vendor that uses an existing rendering engine needs to manually enable each individual feature. That would at least prevent browser vendors from shipping APIs for features that they did not actually do any work on.