Our journey to understand scrolling across different browsers
Performance

Our journey to understand scrolling across different browsers

Omer Doron
Omer Doron

When we started optimizing users’ scroll experience in our client-side web application, which is powered by React and Redux, we needed to dive into how different browsers work with regards to scroll events. Our boards are customized tables of data, consisting of many complex React components. Since we can’t render the entire cells of a complex board, we use recycling and windowing mechanisms in order to optimize the scrolling experience. Implementing the windowing mechanism involves controlling browsers’ scrolling mechanisms and this is where our journey began in understanding how different browsers work.

For those who are not familiar with recycling and windowing mechanisms, I recommend reading Moshe’s post first. This post describes how we optimized the scrolling experience by implementing a recycling list that updates the visible components in the page based on scroll events.

My blog post is aimed to help you better understand how different modern browsers handle scroll events and to give you examples of changes you can make to control the scrolling experience.

Our world — Updating the React components list based on scroll events

The first problem we encountered was when the update of the React list wasn’t fast enough, i.e. scroll events took more than 16.67 ms resulting in less than 60 FPS. In this case, the default behaviour of browsers is to update the scrolling position of the scrollable element, even if the UI can’t catch up and present the relevant components. This can result in white spaces during scrolling, which we call Blank Spots.

In the example below, you can see that when scrolling, the rendering isn’t fast enough, and therefore we have Blank Spots.

Blank Spaces

With that in mind, we preferred to get full control of the scrolling behaviour and to provide a smooth scrolling experience without Blank Spots, even if it would require a little reduction in the default scrolling speed.

How browsers’ scrolling works — understanding events, threads, and synchronous and asynchronous scrolling

In order to explain the steps we took to solve Blank Spots, I will explain the different events that take place when users scroll and how different threads in the browsers interact with these events.

Scrolling related events — Wheel and scroll events

A wheel event fires when users rotate a wheel button on a pointing device (mouse). By default, following the wheel event, browsers will trigger the wheel event handler and will trigger the scroll events

When a user scrolls, a wheel event is fired, browsers update scrollable elements with new scroll offsets, and at the same time, trigger a scroll event to the main thread, which triggers the scroll event handler, which in our case updates the components that the users see. So, if the scroll event handler isn’t fast enough, the time gap between updating the scrollable element offsets and updating the React components will result in Blank Spots.

An important property of the wheel event is passive, meaning you can tell the browser to cancel its asynchronous behavior , by invoking the preventDefault() method in your wheel handler. For example, this is how some map views on the web implement zooming instead of scrolling, when users scroll with their mouse.

Note that in order to make sure preventDefault() works, the event listener options should be defined as passive:false. passive:true will cause browsers to ignore preventDefault() and some browsers change the default to be true (Chrome and Firefox).

More info about passive events is available here.

A word about different browsers’ threads

To keep it simple, I won’t elaborate about the different browsers’ threads and how they interact with each other, but here is a short explanation:

  • Scrolling thread — All major browser engines (Blink, EdgeHTML, Gecko, WebKit) support off-main-thread scrolling. Scrolling is managed by a different thread than the main thread. More info is available here.
  • Main thread (or the UI thread) — The thread that runs our code and is in charge of JS, style calculations, building the layer tree, and paint (or rasterization).
  • Compositor thread — Composites all the layers, creates the final image and sends it to the monitor. Any update to the compositor thread will create a new image. This image will be sent to the monitor (screen). During native async scrolling, the compositor will create images in 60 FPS, even if the main thread is running at a lower frame rate. More info is available here.

Different approaches for updating the DOM during scrolling — asynchronous and synchronous scrolling

Bare with me, we almost have the full picture of how it works. In the example below, you can see an illustration of how wheel and scroll events trigger different handlers on different threads. We call it asynchronous scrolling since the UI updates the scrollable elements positions even if it doesn’t finish adding the relevant elements into the DOM.

Asynchronous Scrolling

Using the solution outlined below, we stop asynchronous scrolling and instead have synchronous scrolling, in which we gain full control of the scrolling experience.

Synchronous Scrolling

Steps to change the scrolling experience from asynchronous to synchronous on different browsers

Now I will explain the practicalities for changing the scrolling experience on different browsers and some tips for avoiding bugs.

Step 1 —Prevent wheel events before they trigger scroll events

As mentioned above, we need to make sure that the wheel event handler will be defined with the passive:false option and then invoke preventDefault().

element.addEventListener("wheel", this.onMouseWheel, { passive: false });
onMouseWheel(e) {
  // will prevent the creation of scroll event
  e.preventDefault();
  // will stop propagation to other wheel event listeners
  e.stopPropagation();
}

Step 2— Calculating the scroll deltas

In order to update the scrollable element to its new positions we first need to calculate how much the users scroll with their mouse. In wheel events, the deltas of the scroll are available on the event object.

Be aware that deltaY and deltaX from the event is not enough. Sometimes the value of the parameters will be in pixels, and sometimes it will be the number of lines. You need to check the deltaMode. If it’s in line mode, you will need to multiply the number of lines by the line-height.

let deltaY = e.deltaY
let deltaX = e.deltaX


// check e.deltaMode, 0 for pixels (default), 1 for lines, 2 for pages
const isLinesScroll = e.deltaMode === 1;
const isPagesScroll = e.deltaMode === 2;


if (isLinesScroll) {
  deltaY = deltaY * LINE_HEIGHT;
  deltaX = deltaX * LINE_HEIGHT;
}


if (isPagesScroll) {
  deltaY = deltaY * PAGE_HEIGHT;
  deltaX = deltaX * PAGE_HEIGHT;
}

Step 3— Update the scrollTop and ScrollLeft of the scrollable element

Now, you can calculate the new scrollTop and scrollLeft of the scrollable element based on the deltas. Keep in mind that by doing so, you invoke the native browser’s scroll event on the next frame.

const newScrollTop = this._scrollElement.scrollTop() + deltaY;
this._scrollElement.scrollTop(newScrollTop);

const newScrollLeft = this._scrollElement.scrollLeft() + deltaX
this._scrollElement.scrollLeft(newScrollLeft);

// get scrollTop and scrollLeft values from the element
// values can't be lower than 0
const scrollTop = this._scrollElement.scrollTop();
const scrollLeft = this._scrollElement.scrollLeft();

this.onScroll(scrollTop, scrollLeft)

Step 4— Invoke your own scroll handler

This is where you can invoke whatever you want to do after the scroll events. Meaning this is your own onScroll trigger. For us, it’s where we update the recycling list to show the relevant list items.

onScroll(scrollTop, scrollLeft) {
  // callback for scroll/wheel events
  // update react/redux state
  // update style of elements in the same frame of the wheel event
}

Step 5— Limit wheel events to one scroll event per frame

Some browsers might trigger more than one wheel event per frame. So to avoid redundant triggering of your own onScroll handler in the same frame, have a flag for disabling future wheel events and remove this flag with requestAnimationFrame.

onMouseWheel(e) {
  // prevent native scroll
  e.preventDefault();
  e.stopPropagation();
  
  // limit the browser to 1 manual scroll event per frame
  if (this.disableScrollEvents) return false;
  
  // trigger manual scroll…
 }

onScroll(e) {
  // do something…
  // stop future scroll events until next frame
  this.disableScrollEvents = true;
  requestAnimationFrame(this.enableScrollEvents); 
}

enableScrollEvents() {
  this.disableScrollEvents = false;
}

Summary

This article dived deep into the way browsers handle scrolling related events. Here is a summary for you to remember:

  • By default, browsers handle scrolling asynchronously, meaning if the handling of scrolling isn’t fast enough, it can result in Blank Spots.
  • Wheel events fire when users scroll with their mouse.
  • Scroll events fire when the scrollable element gets updated.
  • Wheel events are passive, and you can use this property to implement your own scrolling behaviour.
  • There are different threads operating when users scroll — Scroll, Main and Compositor threads.
  • There are several steps that one needs to follow in order to change the scrolling from asynchronous to synchronous.

If these kinds of challenges excite you, we are hiring talented client application experts to join our client foundations team. Check out the open positions on our careers page.

Thank you for reading 🙂