Archive for August, 2019

Browsers, input events, and frame throttling

If there’s one thing I’ve learned about web performance, it’s that you have to approach it with a sense of open-mindedness and humility. Otherwise, prepare to be humbled.

Just as soon as you think you’ve got it all figured out, poof! Browsers change their implementation. Or poof! The spec changes. Or poof! You just flat-out turn out to be wrong. So you have to constantly test and revalidate your assumptions.

In a recent post, I suggested that pointermove events fire more frequently than requestAnimationFrame, and so it’s a good idea to throttle them to rAF. I also rattled off some other events that may fire faster than rAF, such as scroll, wheel, touchmove, and mousemove.

Do these events actually fire faster than rAF, though? It’s an important detail! If browsers already align/throttle these events to rAF, then there’s little point in recreating that same behavior in userland. (Thankfully an extra rAF won’t add an extra frame delay, though, assuming browsers fire the rAF-aligned events right before rAF. Thanks Jake Archibald for this tip!)

TL;DR: it varies across browsers and events. I’d still recommend the rAF-throttling technique described in my previous post.

Step one: check the spec

The first question to ask is: what does the spec say?

After reading the specs for pointermove, mousemove, touchmove, scroll, and wheel, I found that the only mention of animation frame timing was in pointermove and scroll. The spec for pointermove says:

A user agent MUST fire a pointer event named pointermove when a pointer changes button state. […] These events may be coalesced or aligned to animation frame callbacks based on UA decision.

(Emphasis mine.) So browsers are not required to coalesce or align pointermove events to animation frames, but they may do so. (Presumably, this is the point of getCoalescedEvents().)

As for scroll, it’s mentioned in the event loop spec, where it says “for each fully active Document […], run the scroll steps for that Document” as part of the steps before running rAF callbacks. So on the main document at least, scroll is definitely supposed to fire before rAF.

For contrast, here’s touchmove:

A user agent must dispatch this event type to indicate when the user moves a touch point along the touch surface. […] Note that the rate at which the user agent sends touchmove events is implementation-defined, and may depend on hardware capabilities and other implementation details.

(Emphasis mine.) So this time, nothing about animation frames, and also some language about “implementation-defined.” Similarly, here’s mousemove:

The frequency rate of events while the pointing device is moved is implementation-, device-, and platform-specific, but multiple consecutive mousemove events SHOULD be fired for sustained pointer-device movement, rather than a single event for each instance of mouse movement.

(Emphasis mine.) So we’re starting to get a pretty clear picture (or a hazy one, depending on your perspective). It seems that, aside from scroll, the specs don’t have much to say about whether events should be coalesced with rAF or not.

Step two: test it

However, this doesn’t mean browsers don’t do it! After all, it’s clearly in browsers’ interests to coalesce these events to animation frames. Assuming that most web developers do the simplest possible thing and handle the events directly, then any browser that aligns with rAF will avoid some unintentional jank from noisy input events.

Do browsers actually do this, though? Thankfully Jake has written a nice demo which makes it easy to test this. I’ve also extended his demo to test scroll events. And because I apparently have way too much free time on my hands (or I just hate uncertainty when it comes to browser stuff), I went ahead and compiled the data for various browsers and OSes:

pointermove mousemove touchmove wheel scroll
Chrome 76 (Windows 10) Y* Y* N/A Y* Y
Firefox 68 (Windows 10) Y Y N/A N Y
Edge 18 (Windows 10) N N N/A N Y
Chrome 76 (macOS 10.14.6) Y* Y* N/A Y* Y
Firefox 68 (macOS 10.14.6) Y Y N/A N Y
Safari 12.1.2 (macOS 10.14.6) N/A N N/A N N
Safari Technology Preview 13.1 (macOS 10.14.6) N N N/A N N
Chrome 76 (Ubuntu 16.04) Y* Y* N/A Y* Y
Firefox 68 (Ubuntu 16.04) Y Y N/A N Y
GNOME Web 3.28.5 (Ubuntu 16.04) N/A N N/A N N
Chrome 76 (Android 6) Y N/A Y N/A Y
Firefox 68 (Android 6) N/A N/A Y N/A Y
Safari (iOS 12.4) N/A N/A Y N/A N

Abbreviations:

  • Y: Yes, events are coalesced and aligned to rAF.
  • N: No, events fire independently of and faster than rAF.
  • N/A: Event doesn’t apply to this device/browser.
  • *: Except when Dev Tools are opened, apparently.

Conclusion

As you can see from the data, there is a lot of variance in terms of which events and browsers align to rAF. Although for the most part, it seems consistent within browser engines (e.g. GNOME Web is a WebKit-based browser, and it patterns with macOS Safari). Note though that I only tested a regular mouse or trackpad, not exotic input devices such as a Wacom stylus, Surface Pen, etc.

Given this data, I would take the cautious approach and still do the manual rAF-throttling as described in my previous blog post. It has the upside of being guaranteed to work roughly the same across all browsers, at the cost of some extra bookkeeping. [1]

Depending on your supported browser matrix, though, and depending on when you’re reading this (maybe at a point in the future when all browser input events are rAF-aligned!), then you may just handle the input directly and trust the browser to align it to rAF. [2]

Thanks to Ben Kelly and Jake Archibald for feedback on a draft of this blog post. Thanks also to Jake for clueing me in to this rAF-throttling business in the first place.

Footnotes

1. Interestingly, in the case of pointermove at least, the browser behavior can be feature-detected by checking getCoalescedEvents (i.e. Firefox and Chrome have it, Edge and Safari Technology Preview don’t). So you can use PointerEvent.prototype.getCoalescedEvents as a feature check. But there’s little point in feature-detecting, since manual rAF-throttling doesn’t add an extra frame delay in browsers that already rAF-align.

2. Jake also pointed me to an interesting detail: “Although these events are synced to rendering, they’ll flush if another non-synced event happens.” So for instance, keyboard events will interfere with pointermove and cause them to no longer sync to rAF, which you can reproduce in Jake’s demo by typing on the keyboard and moving the mouse at the same time. Another good reason to just rAF-throttle and be sure!

High-performance input handling on the web

Update: In a follow-up post, I explore some of the subtleties across browsers in how they fire input events.

There is a class of UI performance problems that arise from the following situation: An input event is firing faster than the browser can paint frames.

Several events can fit this description:

  • scroll
  • wheel
  • mousemove
  • touchmove
  • pointermove
  • etc.

Intuitively, it makes sense why this would happen. A user can jiggle their mouse and deliver precise x/y updates faster than the browser can paint frames, especially if the UI thread is busy and thus the framerate is being throttled (also known as “jank”).

Screenshot of Chrome Dev Tools showing that a long frame of 546ms can contain as many as four pointermove events

In the above screenshot, pointermove events are firing faster than the framerate can keep up.[1] This can also happen for scroll events, touch events, etc.

Update: In Chrome, pointermove is actually supposed to align/throttle to requestAnimationFrame automatically, but there is a bug where it behaves differently with Dev Tools open.

The performance problem occurs when the developer naïvely chooses to handle the input directly:

element.addEventListener('pointermove', () => {
  doExpensiveOperation()
})

In a previous post, I discussed Lodash’s debounce and throttle functions, which I find very useful for these kinds of situations. Recently however, I found a pattern I like even better, so I want to discuss that here.

Understanding the event loop

Let’s take a step back. What exactly are we trying to achieve here? Well, we want the browser to do only the work necessary to paint the frames that it’s able to paint. For instance, in the case of a pointermove event, we may want to update the x/y coordinates of an element rendered to the DOM.

The problem with Lodash’s throttle()/debounce() is that we would have to choose an arbitrary delay (e.g. 20 milliseconds or 50 milliseconds), which may end up being faster or slower than the browser is actually able to paint, depending on the device and browser. So really, we want to throttle to requestAnimationFrame():

element.addEventListener('pointermove', () => {
  requestAnimationFrame(doExpensiveOperation)
})

With the above code, we are at least aligning our work with the browser’s event loop, i.e. firing right before style and layout are calculated.

However, even this is not really ideal. Imagine that a pointermove event fires three times for every frame. In that case, we will essentially do three times the necessary work on every frame:

Chrome Dev Tools screenshot showing an 82 millisecond frame where there are three pointermove events queued by requestAnimationFrame inside of the frame

This may be harmless if the code is fast enough, or if it’s only writing to the DOM. However, if it’s both writing to and reading from the DOM, then we will end up with the classic layout thrashing scenario,[2] and our rAF-based solution is actually no better than handling the input directly, because we recalculate the style and layout for every pointermove event.

Chrome Dev Tools screenshot of layout thrashing, showing two pointermove events with large Layout blocks and the text "Forced reflow is a likely performance bottleneck"

Note the style and layout recalculations in the purple blocks, which Chrome marks with a red triangle and a warning about “forced reflow.”

Throttling based on framerate

Again, let’s take a step back and figure out what we’re trying to do. If the user is dragging their finger across the screen, and pointermove fires 3 times for every frame, then we actually don’t care about the first and second events. We only care about the third one, because that’s the one we need to paint.

So let’s only run the final callback before each requestAnimationFrame. This pattern will work nicely:

function throttleRAF () {
  let queuedCallback
  return callback => {
    if (!queuedCallback) {
      requestAnimationFrame(() => {
        const cb = queuedCallback
        queuedCallback = null
        cb()
      })
    }
    queuedCallback = callback
  }
}

We could also use cancelAnimationFrame for this, but I prefer the above solution because it’s calling fewer DOM APIs. (It only calls requestAnimationFrame() once per frame.)

This is nice, but at this point we can still optimize it further. Recall that we want to avoid layout thrashing, which means we want to batch all of our reads and writes to avoid unnecessary recalculations.

In “Accurately measuring layout on the web”, I explore some patterns for queuing a timer to fire after style and layout are calculated. Since writing that post, a new web standard called requestPostAnimationFrame has been proposed, and it fits the bill nicely. There is also a good polyfill called afterframe.

To best align our DOM updates with the browser’s event loop, we want to follow these simple rules:

  1. DOM writes go in requestAnimationFrame().
  2. DOM reads go in requestPostAnimationFrame().

The reason this works is because we write to the DOM right before the browser will need to calculate style and layout (in rAF), and then we read from the DOM once the calculations have been made and the DOM is “clean” (in rPAF).

If we do this correctly, then we shouldn’t see any warnings in the Chrome Dev Tools about “forced reflow” (i.e. a forced style/layout outside of the browser’s normal event loop). Instead, all layout calculations should happen during the regular event loop cycle.

Chrome Dev Tools screenshot showing one pointermove per frame and large layout blocks with no "forced reflow" warning

In the Chrome Dev Tools, you can tell the difference between a forced layout (or “reflow”) and a normal one because of the red triangle (and warning) on the purple style/layout blocks. Note that above, there are no warnings.

To accomplish this, let’s make our throttler more generic, and create one that can handle requestPostAnimationFrame as well:

function throttle (timer) {
  let queuedCallback
  return callback => {
    if (!queuedCallback) {
      timer(() => {
        const cb = queuedCallback
        queuedCallback = null
        cb()
      })
    }
    queuedCallback = callback
  }
}

Then we can create multiple throttlers based on whether we’re doing DOM reads or writes:[3]

const throttledWrite = throttle(requestAnimationFrame)
const throttledRead = throttle(requestPostAnimationFrame)

element.addEventListener('pointermove', e => {
  throttledWrite(() => {
    doWrite(e)
  })
  throttledRead(() => {
    doRead(e)
  })
})

Effectively, we have implemented something like fastdom, but using only requestAnimationFrame and requestPostAnimationFrame!

Pointer event pitfalls

The last piece of the puzzle (at least for me, while implementing a UI like this), was to avoid the pointer events polyfill. I found that, even after implementing all the above performance improvements, my UI was still janky in Firefox for Android.

After some digging with WebIDE, I found that Firefox for Android currently does not support Pointer Events, and instead only supports Touch Events. (This is similar to the current version of iOS Safari.) After profiling, I found that the polyfill itself was taking up a lot of my frame budget.

Screenshot of Firefox WebIDE showing a lot of time spent in pointer-events polyfill

So instead, I switched to handling pointer/mouse/touch events myself. Hopefully in the near future this won’t be necessary, and all browsers will support Pointer Events! We’re already close.

Here is the before-and-after of my UI, using Firefox on a Nexus 5:

 

When handling very performance-sensitive scenarios, like a UI that should respond to every pointermove event, it’s important to reduce the amount of work done on each frame. I’m sure that this polyfill is useful in other situations, but in my case, it was just adding too much overhead.

One other optimization I made was to delay updates to the store (which trigger some extra JavaScript computations) until the user’s drag had completed, instead of on every drag event. The end result is that, even on a resource-constrained device like the Nexus 5, the UI can actually keep up with the user’s finger!

Conclusion

I hope this blog post was helpful for anyone handling scroll, touchmove, pointermove, or similar input events. Thinking in terms of how I’d like to align my work with the browser’s event loop (using requestAnimationFrame and requestPostAnimationFrame) was useful for me.

Note that I’m not saying to never use Lodash’s throttle or debounce. I use them all the time! Sometimes it makes sense to just let a timer fire every n milliseconds – e.g. when debouncing window resize events. In other cases, I like using requestIdleCallback – for instance, when updating a non-critical part of the UI based on user input, like a “number of characters remaining” counter when typing into a text box.

In general, though, I hope that once requestPostAnimationFrame makes its way into browsers, web developers will start to think more purposefully about how they do UI updates, leading to fewer instances of layout thrashing. fastdom was written in 2013, and yet its lessons still apply today. Hopefully when rPAF lands, it will be much easier to use this pattern and reduce the impact of layout thrashing on web performance.

Footnotes

1. In the Pointer Events Level 2 spec, it says that pointermove events “may be coalesced or aligned to animation frame callbacks based on UA decision.” So hypothetically, a browser could throttle pointermove to fire only once per rAF (and if you need precise x/y events, e.g. for a drawing app, you can use getCoalescedEvents()). It’s not clear to me, though, that any browser actually does this. Update: see comments below, some browsers do! In any case, throttling the events to rAF in JavaScript accomplishes the same thing, regardless of UA behavior.

2. Technically, the only DOM reads that matter in the case of layout thrashing are DOM APIs that force style/layout, e.g. getBoundingClientRect() and offsetLeft. If you’re just calling getAttribute() or classList.contains(), then you’re not going to trigger style/layout recalculations.

3. Note that if you have different parts of the code that are doing separate reads/writes, then each one will need its own throttler function. Otherwise one throttler could cancel the other one out. This can be a bit tricky to get right, although to be fair the same footgun exists with Lodash’s debounce/throttle.