requestAnimationFrame is an API that was originally created by Mozilla but has found its way into Chrome and I think it has huge, huge implications for user interface. A lot of the examples I’ve seen have talked about how you can use it to optimize animation, which makes sense. The basic premise of requestAnimationFrame is that instead of trying to move things out of sync with the browser, you get a hook directly into the browser refresh/redraw and on that refresh you can tell the browser to do something specific with requestAnimationFrame. So it’s sort of like a queuing system whereby you tell the browser what you want to happen, and when it goes through its next round of repaints, it executes what’s in that queue. This makes perfect sense for animation since the animation can’t move any faster than the repaint cycles of the browser. But it also turns out it makes great sense to use it for dragging events as well.
The Problem:
For me, the problem manifested when I was working on making the Brackets sidebar resizable. Like with a lot of resize events I was doing the resize on a mousemove event, so I’d track the mousemove and then resize the sidebar accordingly as the mouse moved. In theory it seemed like a decent way to do things until I opened it up in the Chrome Developer tools:
That screenshot may be a bit confusing, but basically, every mouse move there are a TON of “Recalculate Style/Paint” events happening. That means every single mousemove the browser is trying to repaint and so each mousemove event takes about 50 milliseconds to process. That may not sound like much, but think about how often mousemove fires. What’s worse is that because of how the browser redraws, even though it’s trying to redraw everything on mouse move, it can’t actually do that because the mousemove events are happening faster than the browser can redraw. Here’s the code that’s doing all that repainting:
/** * @private * Sets sidebar width and resizes editor. Does not change internal sidebar open/closed state. * @param {number} width Optional width in pixels. If null or undefined, the default width is used. * @param {!boolean} updateMenu Updates "View" menu label to indicate current sidebar state. * @param {!boolean} displayTriangle Display selection marker triangle in the active view. */ function _setWidth(width, updateMenu, displayTriangle) { // if we specify a width with the handler call, use that. Otherwise use // the greater of the current width or 200 (200 is the minimum width we'd snap back to) var prefs = PreferencesManager.getPreferenceStorage(PREFERENCES_CLIENT_ID, defaultPrefs), sidebarWidth = Math.max(prefs.getValue("sidebarWidth"), 10); width = width || Math.max($sidebar.width(), sidebarWidth); if (typeof displayTriangle === "boolean") { var display = (displayTriangle) ? "block" : "none"; $sidebar.find(".triangle-visible").css("display", display); } if (isSidebarClosed) { $sidebarResizer.css("left", 0); } else { $sidebar.width(width); $sidebarResizer.css("left", width - 1); // the following three lines help resize things when the sidebar shows $sidebar.find(".sidebar-selection").width(width); $projectFilesContainer.triggerHandler("scroll"); $openFilesContainer.triggerHandler("scroll"); if (width > 10) { prefs.setValue("sidebarWidth", width); } } if (updateMenu) { var text = (isSidebarClosed) ? "Show Sidebar" : "Hide Sidebar"; $sidebarMenuText.first().text(text); } EditorManager.resizeEditor(); } /** * @private * Install sidebar resize handling. */ function _initSidebarResizer() { var $mainView = $(".main-view"), $body = $(document.body), prefs = PreferencesManager.getPreferenceStorage(PREFERENCES_CLIENT_ID, defaultPrefs), sidebarWidth = prefs.getValue("sidebarWidth"), startingSidebarPosition = sidebarWidth; $sidebarResizer.css("left", sidebarWidth - 1); if (prefs.getValue("sidebarClosed")) { toggleSidebar(sidebarWidth); } else { _setWidth(sidebarWidth, true, true); } $sidebarResizer.on("dblclick", function () { if ($sidebar.width() === 1) { // mousedown is fired first. Sidebar is already toggeled open to 1px. _setWidth(null, true, true); } else { toggleSidebar(); } }); $sidebarResizer.on("mousedown.sidebar", function (e) { var startX = e.clientX; $body.toggleClass("resizing"); // check to see if we're currently in hidden mode if (isSidebarClosed) { toggleSidebar(1); } $mainView.on("mousemove.sidebar", function (e) { var doResize = true, newWidth = Math.max(e.clientX, 0); // if we've gone below 10 pixels on a mouse move, and the // sidebar is shrinking, hide the sidebar automatically an // unbind the mouse event. if ((startX > 10) && (newWidth < 10)) { toggleSidebar(startingSidebarPosition); $mainView.off("mousemove.sidebar"); $body.toggleClass("resizing"); doResize = false; } else if (startX < 10) { // reset startX if we're going from a snapped closed position to open startX = startingSidebarPosition; } if (doResize) { // if we've moving past 10 pixels, make the triangle visible again // and register that the sidebar is no longer snapped closed. var forceTriangle = null; if (newWidth > 10) { forceTriangle = true; } _setWidth(newWidth, false, forceTriangle); } if (newWidth === 0) { $mainView.off("mousemove.sidebar"); $("body").toggleClass("resizing"); } e.preventDefault(); }); $mainView.one("mouseup.sidebar", function (e) { $mainView.off("mousemove.sidebar"); $body.toggleClass("resizing"); startingSidebarPosition = $sidebar.width(); }); e.preventDefault(); }); } |
The main issue is that there is a lot of jQuery selecting (and then setting) that’s going on. Look at the mousemove event handler and see how much is going on there. That’s kind of bad. It not only does some math, it also calls _setWidth(), which goes through and modifies significant parts of the DOM. It turns out that it’s pretty expensive to pull from and modify the DOM, and when it’s happening a lot every time the mouse moves, you’re going to get a significant bottleneck. If only there was a way to only do all of that getting and setting when the browser could handle it. That’s the beauty of requestAnimationFrame
Optimizing with requestAnimationFrame
requestAnimationFrame is a pretty straightforward API to use, but it took me a little bit to figure it out. Basically you call window.webkitRequestAnimationFrame() (that’s the webkit-specific prefix) and pass in a function that will be called every time the browser gets to the point where it can redraw the page. What’s a little tricky is that you have to tell the browser to keep listening for it because normally it just gets called once. So what I found easiest was to just call window.webkitRequestAnimationFrame() at the end of the function you’re passing into requestAnimationFrame. Here’s my rewritten example of the _initSidebarResizer():
function _initSidebarResizer() { var $mainView = $(".main-view"), $body = $(document.body), prefs = PreferencesManager.getPreferenceStorage(PREFERENCES_CLIENT_ID, defaultPrefs), sidebarWidth = prefs.getValue("sidebarWidth"), startingSidebarPosition = sidebarWidth, animationRequest = null, isMouseDown = false; $sidebarResizer.css("left", sidebarWidth - 1); if (prefs.getValue("sidebarClosed")) { toggleSidebar(sidebarWidth); } else { _setWidth(sidebarWidth, true, true); } $sidebarResizer.on("dblclick", function () { if ($sidebar.width() === 1) { // mousedown is fired first. Sidebar is already toggeled open to 1px. _setWidth(null, true, true); } else { toggleSidebar(); } }); $sidebarResizer.on("mousedown.sidebar", function (e) { var startX = e.clientX, newWidth = Math.max(startX, 0), doResize = true; isMouseDown = true; // take away the shadows (for performance reasons during sidebarmovement) $sidebar.find(".scroller-shadow").css("display", "none"); $body.toggleClass("resizing"); // check to see if we're currently in hidden mode if (isSidebarClosed) { toggleSidebar(1); } animationRequest = window.webkitRequestAnimationFrame(function doRedraw() { // only run this if the mouse is down so we don't constantly loop even // after we're done resizing. if (isMouseDown) { // if we've gone below 10 pixels on a mouse move, and the // sidebar is shrinking, hide the sidebar automatically an // unbind the mouse event. if ((startX > 10) && (newWidth < 10)) { toggleSidebar(startingSidebarPosition); $mainView.off("mousemove.sidebar"); $body.toggleClass("resizing"); doResize = false; } else if (startX < 10) { // reset startX if we're going from a snapped closed position to open startX = startingSidebarPosition; } if (doResize) { // if we've moving past 10 pixels, make the triangle visible again // and register that the sidebar is no longer snapped closed. var forceTriangle = null; if (newWidth > 10) { forceTriangle = true; } // for right now, displayTriangle is always going to be false for _setWidth // because we want to hide it when we move, and _setWidth only gets called // on mousemove now. _setWidth(newWidth, false, false); } if (newWidth === 0) { $mainView.off("mousemove.sidebar"); $("body").toggleClass("resizing"); } animationRequest = window.webkitRequestAnimationFrame(doRedraw); } }); $mainView.on("mousemove.sidebar", function (e) { newWidth = Math.max(e.clientX, 0); e.preventDefault(); }); $mainView.one("mouseup.sidebar", function (e) { isMouseDown = false; // replace shadows and triangle $sidebar.find(".triangle-visible").css("display", "block"); $sidebar.find(".scroller-shadow").css("display", "block"); EditorManager.resizeEditor(); $projectFilesContainer.triggerHandler("scroll"); $openFilesContainer.triggerHandler("scroll"); $mainView.off("mousemove.sidebar"); $body.toggleClass("resizing"); startingSidebarPosition = $sidebar.width(); }); e.preventDefault(); }); } |
There are a few changes there but the biggest is that the mousemove event only sets the newWidth property based on the mouse event. Nothing else. That keeps mousemove, which happens a lot, nice and small. Everything else has been moved up to mousedown but wrapped in a function named doResize(), which is passed into window.webkitRequestAnimationFrame(). So instead of doing everything on mousemove I’m telling the browser that I only want to do the heavy lifting when the browser is going to do a repaint anyway. And because the newWidth is being updated by mousemove I’ve got the correct width updated and the browser will repaint the sidebar in the correct position.
One thing that confused me was how to stop requestAnimationFrame. There is a cancelRequestAnimationFrame but no one seems to use it because requestAnimationFrame() will run only one extra time when it’s called unless you tell it otherwise. Unfortunately, that’s exactly what this code does because we recursively call requestAnimationFrame() as soon as the function finishes so that if the page is still being dragged, we can get new values. But if we don’t find a way to stop it, the browser will constantly try to repaint even if there’s not a drag event going on. That’s why I use the isMouseDown boolean. If the mouse isn’t down, it’s a way to tell requestAnimationFrame() that I don’t want it to fire any more so it’s going to run once more, then stop.
The result is pretty significant:
The mousemove event now takes 0ms, and since it happens often, that’s a fantastic improvement right off the bat. And now all of the layout/paint events are happening based on an Animation Frame Event instead of on mousemove. You can also see spots where mousemove fires a couple of times before the Animation Frame Event fires and in the old code that would have caused an unnecessary layout/repaint series because even though the browser was doing all of the leg-work on it, it wouldn’t even be able to render it until the next animation frame came up. This way we’re only having the browser do the work when it can.
This is more of a real-world scenario of why requestAnimationFrame can be helpful. Now that I’m digging into it more I’m looking at doing a couple of posts on the basics and starting from a smaller example with requestAnimationFrame but this example made me a believer in how cool it is so I wanted to share.
Huge thanks to Paul Irish for info on using requestAnimationFrame and chatting about it. Talking it through helped a ton to wrap my head around it.
Tweet

