When JavaScript Feature Detection Fails

Once upon a time, browser detection was the stock-in-trade of JavaScript programmers. If we knew that something worked in IE5 but not in Netscape 4, we’d test for that browser and fork the code accordingly. Something like this:

if(navigator.userAgent.indexOf('MSIE 5') != -1)
{
  //we think this browser is IE5
}

But the arms-race was already well underway when I first joined this industry! Vendors were adding extra values to the user-agent string, so they’d appear to be their competitor’s browser, as well as their own. For example, this is Safari 5 for Mac:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/534.59.10 (KHTML, like Gecko) Version/5.1.9 Safari/534.59.10

That will match tests for Safari and Webkit as well as KHTML (the Konqueror codebase that Webkit is based on); but it also matches Gecko (which is Firefox’s rendering engine), and of course Mozilla (because almost every browser claims to be Mozilla, for historical reasons).

The purpose of adding all these values is to circumvent browser detection. If a script assumes that only Firefox can handle a particular function, it might otherwise exclude Safari, even though it would probably work. And don’t forget that users themselves can change their user-agent — I’ve been known to set my browser to identify as Googlebot/1.0, so I can access content the site-owner thinks is only available for crawling!

So over time, this kind of browser detection has become an impossible tangle, and has largely fallen out of use, to be superseded by something far better — feature detection.

Feature detection simply tests for the features we want to use. For example, if we need getBoundingClientRect (to get the position of an element relative to the viewport), then the important thing is whether the browser supports it, not what browser that is; so rather than testing for supported browsers, we test for the feature itself:

if(typeof document.documentElement.getBoundingClientRect != "undefined")
{
  //the browser supports this function
}

Browsers which don’t support that function will return a type of "undefined", and therefore won’t pass the condition. Without us having to test the script in any specific browser, we know it will either work correctly, or silently fail.

Or do we …?

But here’s the thing — feature detection isn’t completely reliable either — there are times where it fails. So let’s take a look at some examples now, and see what we can do to solve each case.

The ActiveX object

Perhaps the most famous example of where feature detection fails, is testing for ActiveXObject to make an Ajax request in Internet Explorer.

ActiveX is an example of a late binding object, the practical meaning of which is that you can’t know whether it will be supported until you try to use it. So code like this will throw an error if the user has ActiveX disabled:

if(typeof window.ActiveXObject != "undefined")
{
  var request = new ActiveXObject("Microsoft.XMLHTTP");
}

To solve this problem we need to use exception handlingtry to instantiate the object, catch any failure, and deal with it accordingly:

if(typeof window.ActiveXObject != "undefined")
{
  try
  {
    var request = new ActiveXObject("Microsoft.XMLHTTP");
  }
  catch(ex)
  {
    request = null;
  }
  if(request !== null)
  {
    //... we have a request object
  }
}

HTML attributes mapped to DOM properties

Property mappings are often used to test support for the API that goes with an HTML5 attribute. For example, checking that an element with [draggable="true"] supports the Drag and Drop API, by looking for the draggable property:

if("draggable" in element)
{
  //the browser supports drag and drop
}

The problem here is that IE8 or earlier automatically maps all HTML attributes to DOM properties. This is why getAttribute is such a mess in these older versions, because it doesn’t return an attribute at all, it returns a DOM property.

This means that if we use an element which already has the attribute:

<div draggable="true"> ... </div>

Then the draggable test will return true in IE8 or earlier, even though they don’t support it.

The attribute could be anything:

<div nonsense="true"> ... </div>

But the result will be the same — IE8 or earlier will return true for ("nonsense" in element).

The solution in this case is to test with an element which doesn’t have the attribute, and the safest way to do that is to use a created element:

if("draggable" in document.createElement("div"))
{
  //the browser really supports drag and drop
}

Assumptions about user behaviour

You might have seen code like this used to detect touch devices:

if("ontouchstart" in window)
{
  //this is a touch device
}

Most touch devices implement an artificial delay before firing click events (usually around 300ms), which is so that elements can be double-tapped without clicking them as well. But this can make an application feel sluggish and unresponsive, so developers sometimes fork events using that feature test:

if("ontouchstart" in window)
{
  element.addEventListener("touchstart", doSomething);
}
else
{
  element.addEventListener("click", doSomething);
}

However this condition proceeds from a false assumption — that because a device supports touch, therefore touch will be used. But what about touch-screen laptops? The user might be touching the screen, or they might be using a mouse or trackpad; the code above can’t handle that, so clicking with the mouse would do nothing at all.

The solution in this case is not to test for event support at all — instead, bind both events at once, and then use preventDefault to stop the touch from generating a click:

element.addEventListener("touchstart", function(e)
{
  doSomething();
  
  e.preventDefault();
  	
}, false);
  
element.addEventListener("click", function()
{
  doSomething();
  
}, false);

Stuff that just plain doesn’t work

It’s a painful thing to concede, but sometimes it isn’t the feature we need to test for — it’s the browser — because a particular browser claims support for something that doesn’t work. A recent example of this is setDragImage() in Opera 12 (which is a method of the drag and drop dataTransfer object).

Feature testing fails here because Opera 12 claims to support it; exception handling won’t help either, because it doesn’t throw any errors. It just plain doesn’t work:

//Opera 12 passes this condition, but the function does nothing
if("setDragImage" in e.dataTransfer)
{
  e.dataTransfer.setDragImage("ghost.png", -10, -10);
}

Now that might be fine if all you want is to try adding a custom drag image, and are happy to leave the default if that’s not supported (which is what will happen). But what if your application really needs a custom image, to the extent that browsers which don’t support it should be given an entirely different implementation (i.e. using custom JavaScript to implement all the drag behaviors)?

Or what if a browser implements something, but with rendering bugs that can’t be prevented? Sometimes we have no choice but to explicitly detect the browser in question, and exclude it from using a feature it would otherwise try to support.

So the question becomes — what’s the safest way to implement browser detection?

I have two recommendations:

  1. Use proprietary object tests in preference to navigator information.
  2. Use it for excluding browsers rather than including them.

For example, Opera 12 or earlier can be detected with the window.opera object, so we could test for draggable support with that exclusion:

if(!window.opera && ("draggable" in document.createElement("div")))
{
  //the browser supports drag and drop but is not Opera 12
}

It’s better to use proprietary objects rather than standard ones, because the test result is less like to change when a new browser is released. Here are some of my favourite examples:

if(window.opera)
{
  //Opera 12 or earlier, but not Opera 15 or later
}
if(document.uniqueID)
{
  //any version of Internet Explorer
}
if(window.InstallTrigger)
{
  //any version of Firefox
}

Object tests can also be combined with feature testing, to establish support for a particular feature within a specific browser, or at a pinch, to define more precise browser conditions:

if(document.uniqueID && window.JSON)
{
  //IE with JSON (which is IE8 or later)
}
if(document.uniqueID && !window.Intl)
{
  //IE without the Internationalization API (which is IE10 or earlier)
}

We’ve already noted how the userAgent string is an unreliable mess, but the vendor string is actually quite predictable, and can be used to reliably test for Chrome or Safari:

if(navigator.vendor == 'Google Inc.')
{
  //any version of Chrome
}
if(navigator.vendor == 'Apple Computer, Inc.')
{
  //any version of Safari (including iOS builds)
}

The golden rule with all of this is to be extremely careful. Make sure you test conditions in as many browsers as you can run, and think about them carefully in terms of forward compatibility — aim to use browser conditions for excluding browsers because of a known bug, rather than including them because of a known feature (which is what feature testing is for)

And fundamentally, always start by assuming full compliance with feature testing — assume that a feature will work as expected unless you know otherwise.

Choosing the test syntax

Before we go, I’d like to examine the different kinds of syntax we can use for object and feature tests. For example, the following syntax has become common in recent years:

if("foo" in bar)
{
}

We couldn’t use that in the past because IE5 and its contemporaries threw an error over the syntax; but that’s no longer an issue now that we don’t have to support those browsers.

In essence, it amounts to exactly the same as this, but is shorter to write:

if(typeof bar.foo != "undefined")
{
}

However test conditions are often written with reliance on automatic type conversion:

if(foo.bar)
{
}

We used that syntax earlier in some of the browser object tests (such as the test for window.opera), and that was safe because of how objects evaluate — any defined object or function will always evaluate to true, whereas if it were undefined it would evaluate to false.

But we might be testing something that validly returns null or empty-string, both of which evaluate to false. For example, the style.maxWidth property is sometimes used to exclude IE6:

if(typeof document.documentElement.style.maxWidth != "undefined")
{
}

The maxWidth property only evaluates to true if it’s supported and has an author-defined value, so if we wrote the test like this, it might fail:

if(document.documentElement.style.maxWidth)
{
}

The general rule is this: reliance on automatic type conversion is safe for objects and functions, but is not necessarily safe for strings and numbers, or values which might be null.

Having said that — if you can safely use it, then do so, because it’s usually much faster in modern browsers (presumably because they’re optimized for exactly that kind of condition).

For more about this, see: Automatic Type Conversion In The Real World.

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • lylijincheng

    “The solution in this case is not to test for event support at all — instead, bind both events at once”
    will it execute “doSomething()” twice?

  • brothercake

    No, the preventDefault will stop that.

    So if you click it with the mouse or keyboard, the “click” event will fire. However if a touch interaction fires the “touchstart” event, then the preventDefault will prevent it from firing the “click” event as well.

  • James Edwards

    Touch scripting is a whole world of pain, and way beyond the scope of this article. I really only wanted to make the point that one can’t assume a user with touch is only using touch.

    But you’re right, that example in itself is probably not a complete solution to handling touch-based clicks in most cases.

  • James Edwards

    But apropos of touchend — make sure you test the changedTouches collection, to make sure that the touchend is coming from the same finger that generated the touchstart.