Animation Performance 101: Optimizing Javascript

Your ultimate guide to animation performance — tackling the Javascript beast.

In Part 1 of this series, we went over why CSS is typically better for animation than Javascript — Javascript always runs on the browser’s main thread while CSS can offload to the compositor thread. The main thread is where the browser’s most intensive tasks are run, leading to routine outages that increase the chance of skipped animation frames. Not good for performance!

However, it’s not always possible to avoid Javascript and there are times when you just need it to animate. With that, there are low-effort ways to avoid common performance bottlenecks and still achieve high fidelity. Here’s what to keep in mind:

Animate the Lowest Level Element

Always try to animate the lowest level element (i.e. an element without any children). As we saw in Part 1, changing a parent can force the browser to recalculate styles for each child if they are affected by the change. Going through the rendering waterfall is a highly intensive process that can add up quickly if there are many children. It’s not always easy to avoid, but sometimes moving elements around or using pseudo-elements can help prevent this unintended performance hit.

Use requestAnimationFrame

Remember how most devices these days refresh at a rate of 60 times a second? That means it’s best for our Javascript to run at the very beginning of each refresh, so that we have the maximum time possible to execute and complete the repaint. The only way to guarantee that is with requestAnimationFrame.

requestAnimationFrame tells the browser we are planning to update an animation, and gives it a specific callback function to run before the next repaint. requestAnimationFrame guarantees the callback will run at the start of every frame, syncing our animation to the device’s refresh rate.

There are many benefits to using this native Web API — first, it chooses the appropriate frame rate for each device, running less frequently on devices like low-end phones. This ensures the browser doesn’t have to do any more work than is needed. Second, most browsers automatically pause the callback in background tabs, offloading unnecessary work from the CPU. Lastly, the browser will optimize rendering for animation by batching Layout and Paints, leading to higher fidelity animation.

The first step in using requestAnimationFrame is to define a callback function. The callback is what’s doing the animating — recalculating element positions and setting new styles (what I call dynamic animation in my Beginner’s Guide to Animation). This callback moveBox sets a new margin-left on the box element, making it move to the right 5px.

The second step is to simply pass the callback into requestAnimationFrame().

let oldPosition = 0;

function moveBox() {
  let newPosition = oldPosition + 5;
  box.style.marginLeft = `${newPosition}px`;  

  oldPosition = newPosition; 
}

requestAnimationFrame(moveBox);

With this code, moveBox runs just once — not much of an animation. If you want the function to run continuously, you can call requestAnimationFrame again in the callback:

let oldPosition = 0;

function moveBox() {
  let newPosition = oldPosition + 5;
  box.style.marginLeft = `${newPosition}px`;  

  oldPosition = newPosition; 

  requestAnimationFrame(moveBox); // call again and again
}

requestAnimationFrame(moveBox);

Here is a fleshed out example with extra logic to make the box bounce:


⭐️ Note:

Before requestAnimationFrame, developers used setTimeout and setInterval to achieve a similar effect. They are like timers — you set a time after which the callback should fire. Developers often set a timer of 16 milliseconds to simulate a rate of 60fps (1000ms/60s = 16ms). This pattern works in theory, but falls short because it doesn’t guarantee the callback will run at the beginning of each frame — just that it’ll run every X milliseconds, meaning it can happen at any point in the frame. It’s also unable to account for different device refresh rates. requestAnimationFrame is now preferred to these methods.


Avoid Layout Thrashing

We saw in Part 1 that changing an element’s width using CSS triggers Layout, which is an expensive task because it’s high up on the rendering waterfall and also runs on the main thread.

So it’s important to know you can also trigger Layout using Javascript. There are two ways — reading from the DOM by querying layout, and writing to the DOM by changing layout.

function() {
  console.log(box.offsetWidth); // read from DOM, query layout
  box.style.width = newWidth; // write to DOM, change layout

  doMoreWork();
}

The first thing to note here is that Javascript always caches a snapshot of the previous frame’s layout. So when it needs to console log box.offsetWidth, Javascript can just read from the cached values, making it unnecessary to recalculate layout. The second thing is that when writing to the DOM, the browser is smart enough to not run Layout until the end of Javascript, or until the end of the frame. It will wait for all Javascript to complete before rendering. So in this case, the element’s width is changed on line 3 but the browser won’t actually start Layout until it finishes doMoreWork().

However, you can force the browser to run Layout early if you write to the DOM before reading:

function() {
  box.style.width = newWidth; // write to DOM, invalidating previous snapshot
  console.log(box.offsetWidth); // read from DOM, forcing Layout because previous snapshot was invalidated

  doMoreWork();
}

By changing layout first, you invalidate the previous frame’s snapshot, making it so the console log statement can no longer read from cached values. The browser is forced to run Layout before doMoreWork() in order to provide the updated value. This is called forced synchronous layout. You essentially force the browser to run Layout earlier than it wants to, creating extra work that can lead to stuttering at the start of an animation.

The solution here is to simply do any DOM reads before any DOM writes, as we did in the first example. That way, you take advantage of cached values and let the browser execute rendering in the correct order.

Layout thrashing is when you trigger multiple forced synchronous layouts in quick succession. We can see this with our moving box animation:

function moveBox() {
  // 1) Move box
  if (direction === 'forward') {
    newPosition = oldPosition += 5;
  } else {
    newPosition = oldPosition -= 5;
  }
  box.style.marginLeft = `${newPosition}px`;  // ⚠️ write to DOM, invalidating previous snapshot


  // 2) Determine next direction
  let boxPosition = box.getBoundingClientRect().x;  // ⚠️ read from DOM, forcing Layout because snapshot was invalidated
  let boxWidth    = box.offsetWidth;
  let windowWidth = window.innerWidth;
  let boxHitRightBoundary = boxPosition + boxWidth >= windowWidth;
  let boxHitLeftBoundary  = boxPosition <= 0;

  if (boxHitRightBoundary) {
    direction = 'backward';
  } else if (boxHitLeftBoundary) {
    direction = 'forward';
  }

  // 3) Call requestAnimationFrame again to loop
  requestAnimationFrame(moveBox);  // ⚠️ run repeatedly in quick succession
}

Since the callback will run 60 times a second, it can be a huge hit to performance. Again, the fix here is to do your reads before writes. This means calling any Javascript layout properties and methods before setting new styles or adding classes.

Here’s what the optimized version looks like:

let boxWidth    = box.offsetWidth;  // ✅ cache values ahead of time
let windowWidth = window.innerWidth;

function moveBox() {
  // 1) Determine next direction
  let boxPosition = box.getBoundingClientRect().x; // ✅ read from DOM, taking advantage of cached snapshot
  let boxHitRightBoundary = boxPosition + boxWidth >= windowWidth;
  let boxHitLeftBoundary  = boxPosition <= 0;

  if (boxHitRightBoundary) {
    direction = 'backward';
  } else if (boxHitLeftBoundary) {
    direction = 'forward';
  }
  
  // 2) Move box
  if (direction === 'forward') {
    newPosition = oldPosition += 5;
  } else {
    newPosition = oldPosition -= 5;
  }
  box.style.marginLeft = `${newPosition}px`;  // ✅ write to DOM, browser doesn't execute until Javascript is done

  // 3) Call requestAnimationFrame again to loop
  requestAnimationFrame(moveBox);
}

Besides reading first, we’ve also cached some values ahead of time so that we’re not querying for them in each call. It’s generally good practice to avoid excess DOM querying, as it can get expensive. This optimization has decidedly improved performance that we’ll see in Part 3 on measuring with DevTools.

⭐️ Tips:

  • Check out this comprehensive list to see which Javascript properties and methods force synchronous layout.
  • Elements that are hidden with display: none do not trigger the rendering waterfall when they are changed. If possible, make changes to the element before it becomes visible.

Bonus Tip: Avoid the Main Thread with New Web APIs

This is an exciting time for performant Javascript! There are two new up-and-coming Web APIs that allow you to use Javascript without bogging down the main thread.

The Web Animation API lets you build Javascript animations that run on the compositor thread. It's powered by the same animation engine that drives CSS transitions and keyframe animations. The syntax looks very similar to how you would write CSS keyframes. This API gives you the best of both worlds — CSS’s performance with Javascript’s ability to be dynamic and handle synchronization.

The IntersectionObserver API allows you to be notified when an element scrolls into view. This is supremely useful for things like lazy loading images and animating on scroll. Previously, this was only possible using methods like getBoundingClientRect() that ran in loops on the main thread. Now, this can be done asynchronously by the browser, freeing up the main thread of expensive DOM queries.

Browser support is still catching on but these APIs are already available for 73% of global users according to caniuse.com.

Next Time…

Optimizations are meaningless without data. In Part 3, we’ll look at how to measure runtime performance and diagnose bottlenecks using Chrome DevTools.

Chloe Hwang

Chloe is a Front-end Developer at Viget’s HQ office. She loves constructing clean and responsive interfaces that are as well-built as they are beautiful.

More articles by Chloe