Spencer Ponte Blog

Debouncing Scroll Events with Request Animation Frame

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: