Debouncing Scroll Events with Request Animation Frame

Posted by Spencer on April 23rd, 2015

I was working on an update to Redactor, the text editor used in Beam, that would allow the toolbar to stay visible even when scrolling on long pages. Correctly positioning the toolbar would require using a callback that would need to fire every time the browser was scrolled or resized. Which works, but there’s a problem.

init: function() {
        $(element).on('resize.editor, scroll.editor', scopeC(function() {
                this._toolbarCB();
        }, this));
},

_toolbarCB: function() {
        // calculate position stuff
        // possibly trigger a repaint
}

When scrolling or resizing, the browser can fire off hundreds of events per second. Most browsers only refresh at a rate of 60fps, which means I was wasting lots of resources doing unnecessary work. Ugh.

There’s a wonderful little solution to this problem called requestAnimationFrame (rAF). According to rAF aficionado Paul Irish], this method lets the browser:

optimize concurrent animations together into a single reflow and repaint cycle, leading to higher fidelity animation … Plus, if you’re running the animation loop in a tab that’s not visible, the browser won’t keep it running, which means less CPU, GPU, and memory usage, leading to much longer battery life.

AMAAAZIIIING! But how to use?

My first attempt was to just throw it in there, like this:

init: function() {
        $(element).on('resize.editor, scroll.editor', scopeC(function() {
                window.requestAnimationFrame(scopeC(this._toolbarCB, this));
        }, this));
},

Great, right? Not that simple. With the above code, we’re still throwing hundreds of requests for animation that queue up and ask to be executed- even if one is already currently being executed! This is more unnecessary work for the browser. To really debounce the animation, we should check to see if it’s currently running first before bothering to call it again. Here’s how this was reworked:

init: function() {
        $(element).on('resize.editor, scroll.editor', scopeC(function() {
                this.scrolled = true;
        }, this));

        window.requestAnimationFrame(scopeC(this.check, this));
},

Now the only thing done by the scroll event, the most active part of this process, is to set scrolled to true. rAF itself just calls check(), which calls _toolbarCB() only if we’re actually scrolling.

check: function() {
        if (this.scrolled) {
                this._toolbarCB();
                this.scrolled = false;
        }

        if (this.f_edit)
                window.requestAnimationFrame(scopeC(this.check, this));
}

At the end of check() we create a loop that continues as long as we’re in the editing state. This creates a steady stream, automatically paced by rAF, with no extra calculations/repaints happening unless they should. Silky, smooth, responsible animation.

requestAnimationFrame is relatively new, but available in all of our supported browsers.

Props to the brilliant Westbrook Johnson for bringing this to my attention, as well as discussing and troubleshooting the implementation.

Further reading:

  • MDN docs on rAF
  • Paul Irish, proponent and author of the polyfill, on the benefits of rAF
  • Paul Lewis on debouncing with rAf. He took a similar, but slightly different approach. He performs the check on every scroll event, then only fires to rAF if needed. Contrast that with ours, which moves all the work out of the scroll event, but establishes a steady stream to rAF. If there were many things in the app using rAF, Paul’s way might be better, because it limits the calls to rAF. In our case, it would probably be more work to perform the check on every scroll event than it is to have our stream to rAF.

Posted in Coding