;

Scrolling on the web: A primer

Scrolling is one of the oldest interactions on the web. Long before we had pull-to-refresh or infinite-loading lists, the humble scrollbar solved the web’s original scaling problem: how can we interact with content that’s stretched beyond the available viewport?

Today, scrolling is still the most fundamental interaction on the web, and perhaps the most misunderstood. For instance, do you know the difference between the following scenarios?

If you ask the average web user (or even the average web developer!) they might tell you that these interactions are all equivalent. The truth is far more interesting.

As it turns out, all five of these input methods have vastly different characteristics, especially when it comes to performance and cross-browser behavior. Some of them (such as touch screen scrolling) are likely to be smooth even on a page with heavy JavaScript usage, whereas others (such as keyboard scrolling) will make the same page feel laggy and unresponsive. Furthermore, some kinds of scrolling can be slowed down by DOM event handlers, whereas others won’t. What’s going on here?

To answer that question, and to understand how to unlock the smoothest-possible scrolling on your website, let’s take a step back and understand how browsers deal with multithreading and input.

Conceptually, the web is a single-threaded environment. JavaScript blocks the DOM, and the DOM blocks JavaScript, because both are fighting over the same thread – frequently referred to as the “main thread” or “UI thread.”

For instance, if you were to add this (horrible) JavaScript snippet to your page, you’d immediately see jankiness across the entire user experience:

setInterval(() => {
  var start = Date.now();
  while (Date.now() - start < 500) {/* wheeeee! */}
}, 1000);

While this JavaScript is spinning in a useless loop, buttons don’t work, form elements are unresponsive, and even animated GIFs grind to a halt – for all intents and purposes, the page is frozen. You can see this in action in a simple demo.

Furthermore, if you try to scroll with the keyboard up/down arrows, the page remains predictably stuck until the JavaScript stops running. All of this is strong evidence for our view of the web as a single-threaded environment.

There’s a curious anomaly, though: if you try to scroll using touch screen scrolling, the page happily moves up and down, even while JavaScript is blocking nearly everything else on the page. This also works for touch pad scrolling, mouse wheel scrolling, and click-and-drag scrolling (depending on your browser).

Somehow, certain scroll actions can manipulate the page state, even while everything else – buttons, input, GIFs – are totally frozen. How can we square this with our theory of the single-threaded web?

A tale of two threads

As it turns out, the whole “browsers are single-threaded” story is largely true, but there are important exceptions. Scrolling, in all its various flavors, is one of those exceptions.

Over the years, browser vendors have recognized that offloading work to background threads can yield enormous improvements to smoothness and responsiveness. Scrolling, being so important to the core user experience of every browser, was quickly identified as a ripe target for such optimizations. Nowadays, every major browser engine (Blink, EdgeHTML, Gecko, WebKit) supports off-main-thread scrolling to one degree or another (with Firefox being the most recent member of the club, as of Firefox 46).

With off-thread scrolling, even a noisy page will appear smooth as you scroll through it, because all scrolling is handled on a separate thread. It’s only when you try to interact with the page through some non-scrolling mechanism – tapping a button, typing into an input, clicking a link – that the façade fades away and the parlor trick is revealed for what it is. (Considering how well it works, though, it’s a great trick!)

There is a tradeoff with asynchronous scrolling, however, which is a common effect called checkerboarding, so named because of the way it originally appeared on Safari for iOS (as gray-and-white checkers). In most modern browsers, it manifests as a blank space that appears when you’ve scrolled faster than the browser can paint. This isn’t perfect, but it’s a worthy tradeoff compared to blocked, jerky, or unresponsive scrolling.

Unfortunately, it’s not always easy to move scrolling to a background thread. Browsers can only do this if the host operating system allows for concurrent input, and it can vary from device to device. In particular, keyboard input is not as optimized as input from mouse or touch devices, ultimately leading to laggier keyboard scrolling across browsers.

A little history is instructive here. When operating systems like Windows and macOS were first designed, one thread was all you got, and so there was little foresight to allow for concurrent input. It was only when multi-core machines started to appear that OSes began to retro-fit concurrency into their design.

So, in the same way that vestigial organs can reveal an animal’s evolutionary history, the single-threaded origin of operating systems starts to peek through the seams when you look at scrolling input across the web. It’s only if the host operating system allows for concurrent input – from a mouse, keyboard, or other device – that browsers can efficiently optimize scrolling to be unaffected by long-running JavaScript operations that hog the main thread.

On the Microsoft Edge team, though, we’ve been making strides to ensure that scrolling remains smooth and responsive, regardless of the scrolling method you prefer. As of EdgeHTML 14 (which shipped in the Windows 10 Anniversary Update), we support off-thread scrolling for the following input methods:

If you compare Edge to other desktop browsers, you’ll find that it’s the only one to support asynchronous sidebar scrolling  – i.e. clicking-and-dragging the scroll handle, or clicking on the trackbar or arrows. (In fact, this is a feature we quietly introduced in the Anniversary Update!)

Testing on Windows 10 (14393, Surface Book) and macOS Sierra (10.12, MacBook Air), we can derive the following results:

Two-finger trackpad Touch Mouse wheel Sidebar Keyboard
Edge 14 (Windows) Y Y Y Y N
Chrome 56 (Windows) Y Y Y N N
Firefox 51 (Windows) N N N N N
Chrome 56 (MacOS) Y N/A Y N N
Firefox 51 (MacOS) Y N/A Y N N
Safari 10.1 (MacOS) Y N/A Y N N

As this table demonstrates*, scrolling performance can vary dramatically from browser to browser, and even from OS to OS. If you’re only testing on one browser with one scrolling method, then you might be getting a very narrow view of your site’s performance as it’s actually experienced by users!

Overall, though, it should be clear that scrolling has a special place on the web, and browsers have worked very hard to make sure that scrolling is snappy and responsive. However, there are subtle ways that a web developer can inadvertently disable a browser’s built-in optimizations. Let’s take a look at how web developers can influence browser scrolling, for good and bad.

How event listeners interfere with scrolling

Off-thread scrolling represents a great gain in efficiency – scrolling and JavaScript are completely decoupled, allowing them to work in parallel without blocking each other.

Anyone who’s been building web pages for a while, though, probably knows how to introduce a choke point between JavaScript and scrolling:

window.addEventListener(“wheel”, function (e) {
  e.preventDefault(); // oh no you don’t!
});

When we attach a “wheel” listener that calls event.preventDefault(), it will 100% block scrolling, for both wheel and touch pad scrolling. And obviously, if scrolling is blocked, then off-thread scrolling is also blocked.

What’s less obvious, though, is the impact in this case:

window.addEventListener(“wheel”, function (e) {
  console.log(‘wheel!’);
  // innocent listener, not calling preventDefault()
});

You might naïvely think that a function that doesn’t call preventDefault() can’t block scrolling at all, or that, at the very worst, it can only block for the duration of the function itself. The truth, however, is that even an empty listener will totally block scrolling until any JavaScript operation on the page has finished, which you can verify with this demo.

Even though the wheel listener has nothing to do with our big blocking JavaScript operation, they share the same JavaScript event loop, and so the background thread must wait for the longer JavaScript operation to finish before it can get a response from the listener function.

Why does it have to wait? Well, JavaScript is a dynamic language, and the browser can’t know for sure that preventDefault() will never get called. Even if it’s obvious to the developer that the function is just doing a simple console.log(), browser vendors have opted not to take any chances. In fact, even an empty function() {} will exhibit this behavior.

Note that this applies to more than just “wheel” events: on touchscreen devices, scrolling can also be blocked by “touchstart” or “touchmove” listeners. One should be very careful when adding listeners to a page, because of this impact on performance!

There are a few scroll-related JavaScript APIs, however, that don’t block scrolling. The “scroll” event, somewhat counterintuitively, can’t block scrolling because it fires after the scroll event, and thus it isn’t cancelable. Also, the new Pointer Events API, which was pioneered in IE and Microsoft Edge and has recently started to appear in Chrome and Firefox, has an explicit design goal to avoid unintentional scroll-blocking.

Even in cases where we absolutely need to listen to “wheel” or “touchstart” events, there are certain tricks that web developers can employ to ensure that scrolling remains on the fast path. Let’s take a look at a few of these tricks.

Global vs local event listeners

In the example above, we covered the case of global listeners (i.e. listeners attached to the “window” or “document”). But what about listeners on individually scrolling elements?

In other words, imagine a page that is scrollable, but there is also a subsection of the page that itself is independently scrollable. Will the browser block the entire page’s scrolling if you only attach a listener to the scrollable subsection?

document.getElementById(‘scrollableDiv’)
.addEventListener(“wheel”, function (e) {
  // In theory, I can only block scrolling on the div itself!
});

If you test this out using a simple demo page, you’ll find that both Microsoft Edge and Safari will allow the document to scroll smoothly, even if there’s a scroll listener on the scrollable div, and the page has heavy JavaScript operations running.

Here is a chart of the browsers and their behaviors:

Two-finger touchpad Touch Mouse wheel Click-and-drag Keyboard
Edge 14 Desktop (Windows) Y Y Y Y N
Chrome 56 Desktop (Windows) N Y N N N
Firefox 51 Desktop (Windows) N N N N N
Chrome 56 Desktop (MacOS) N N/A N N N
Firefox 51 Desktop (MacOS) Y N/A Y N N
Safari 10.1 (MacOS) Y N/A Y N N

These results show* that there’s a potential optimization that web developers can employ to take advantage of these browser features. Instead of attaching wheel/touch listeners to the entire document, it’s preferable to add listeners to a targeted subsection of the document, so that scrolling can remain smooth for unaffected parts of the page. In other words, rather than delegating wheel/touchstart listeners to the highest-possible level, it’s best to keep them isolated to the element that needs the scroll event.

Sadly, not all JavaScript frameworks follow this practice – in particular, React tends to add global listeners to the entire document even if it should only apply to a subsection of the page. However, there is an open issue for this very problem, and the React folks have said they would be happy to accept a pull request. (Kudos to the React folks for being so receptive when we provided this feedback!)

Passive event listeners

Avoiding global listeners for wheel/touchstart is a good practice, but sometimes it just isn’t possible, depending on the effect you’re trying to achieve. And in a way, it may feel silly that a simple event listener should force the browser to halt the world, just on the offchance that it might call preventDefault().

Luckily there is a new feature that is just starting to appear in browsers, where web developers can explicitly mark a listener as “passive” and thus avoid waiting:

window.addEventListener(“wheel”, function (e) {
  // Not calling preventDefault()!
}, { passive: true } // I pinkie-swear I won't call preventDefault()
);

With this fix in place, the browser will treat scrolling exactly as if the “wheel” listener had never been added. This feature is already available in the latest versions of Chrome, Firefox, and Safari, and should appear soon in an upcoming release of Microsoft Edge. (Note: you will need to use feature detection to support browsers that don’t have passive event listeners.)

For some events (notably “touchstart,” and “touchmove”) Chrome has also opted for an intervention in version 56 to make them passive by default. Be aware of these subtle browser differences when you add event listeners!

Conclusion

As we’ve seen, scrolling on the web is a fantastically complicated process, and all the browsers are at various stages of improving their performance. In general, though, we can land on some solid pieces of advice for web developers.

First off, it’s best to avoid attaching wheel or touch listeners to the global document or window objects, and instead add them to smaller scrollable elements. Developers should also use passive event listeners whenever possible, with feature detection to avoid compatibility issues. Using Pointer Events (there is a polyfill) and “scroll” listeners are also surefire ways to prevent unintentional scroll-blocking.

Hopefully this post has provided some helpful guidance for web developers, as well as a peek into how browsers work under the covers. No doubt, as browsers evolve and the web continues to grow, scrolling mechanics will become even more complex and sophisticated.

On the Microsoft Edge team, we’re excited to keep innovating in this space and to provide smoother scrolling for more websites and more users. Let’s hear it for the humble scrollbar, the oldest and most nuanced interaction on the web!

Nolan Lawson, Program Manager, Microsoft Edge

* These results were collected using the latest version of each browser as of February 2017. Since then, Firefox 52 has updated their scrolling support, and now matches Edge 14’s behavior in all tests except those for sidebar scrolling. We look forward to all browsers improving their scrolling implementations and making the web faster and more responsive!

Updated March 16, 2017 10:57 am