Performance Goodies for Web Browsers

Oct 6 2017

As web developers we create amazing apps. We have many tools in our arsenal, from frameworks and libraries, to extensions. But at the end, it all reaches the same place: the browser.

A web browser is a software application for retrieving & presenting web pages. In this blog post, we'll focus on optimizing the latter.

UX & FPS

When the user interacts with the app, we want him or her to have a smooth user experience (UX). There are two aspects to consider when it comes to UX.

Respondents

Humans tend to feel something is off or janky when the response time between the action (click a button), and the result (a popup appears, for example) takes more than about 100ms.

Note - According to the Urban Dictionary "janky" is an adjective that means something of "inferior quality; held in low social regard; old and dilapidated." You get the picture. 

Therefore, our app response time should be bellow 100ms - Java Script (JS) that affects the user interface (UI) needs to be completed fast.

FPS

Most modern web-pages use animations. An animation is a series of images, which change over time. The human brain processes around 30 images per second. Animations are measured in terms of Frame Per Second (FPS).

When the images change at a low FPS rate, the animation looks janky, which is bad UX.

Most screens & monitors can display images at 60 FPS, which is around 16ms per frame. Therefore, we want to keep our FPS somewhere around the 30-60 range.

The Browser Tasks

While the browser runs our code, it has other tasks to do as well, such as, for instance, creating the view:

  • Style calculation - Decide which style affects which element on the page.
  • Layouts - Decide where each element is located.
  • Compositing - Decide which element is in front of the other.

Those tasks can take a while to complete, so out of the 16ms we have for each frame, we can use only about 10ms.

Usually, the most time consuming parts of our apps are JS.
If our JS code runs slowly, the browser will not be able to complete the frame in time, dropping the app FPS rate.

Light Events

As we said before, we want the app to be responsive. For example, when the user scrolls up and down, we want smooth motion.

User actions reach our app by listeners, which trigger callbacks we can define. If the callbacks take too long to complete, it will directly affect the app respond time.

Let's say that we have an angular app that looks something like this:

<div ng-mousemove='onMouseMove($event)'>
{{height}}
</div>
scope.onMouseMove = (e) => {
timeConsumingTask(); // JS code that takes 200ms to complete
$timeout(() => {
// changes that affect the UI.
...
}, 0);
}

Well, obviously, if timeConsumingTask takes 200ms, the user will certainly notice.

We can improve that a bit. For one, there is no need to run onMouseMove on every px change. Events can fire more than 30 time per second, which is too fast for humans to notice anyhow.
We can de-bounce events - react only to a single event once in a while.

The second thing we can do is make sure the timeConsumingTask will not block the browsers from completing other tasks that react to the event (such as scrolling down the page or moving the cursor).

We'll move timeConsumingTask into our async timeout block.

scope.doMove = (e) => {
$timeout(() => {
timeConsumingTask(data); // JS code that takes 200ms to complete
// changes that affect the UI.
...
}, 0);
}


scope.onMouseMove = _.debounce(scope.doMove, 16);

Workers

Even though we've made some improvements, we still have a problem, for timeConsumingTask is running on our main thread, the one responsible for all the UI changes as well.

timeConsumingTask will not interfere with the browser's events flow, but still has heavy impact on the UX. To improve that, we can ask a Worker to share the load, and run this task on other thread.

worker = new Worker("worker.js");


scope.onMouseMove = (e) => {
// the worker will run `timeConsumingTask` and let us know when he's done
worker.postMessage(data); 
worker.onmessage = (e) => {
console.log('data processed!');
};


$timeout(() => {
// changes that affect the UI.
...
}, 0);
}

Forced Reflow

Sometimes we do want JS to make changes to the page. And since workers can't access the DOM, we must run the code on the main thread.

const children = element.children();
for (i = 0; i < children.length; i++) {
const c = children[i];
let width = parseInt(c.style.width, 10);
if (c.offsetWidth < 1000) {
c.style.width = `${width + 1}%`;
}
}

This piece of code looks pretty harmless, but does something awful to the browser.
c.offsetWidth reads a layout property from an element, so the browser must calculate the page layout. Then c.style.width makes a change to the element style, affecting the page layout.
On the next loop iteration, the page layout is outdated, so c.offsetWidth forces the browser to calculate the layout again.

This pattern is called Forced Synchronous Layouts.

To make the browser's job easier, we make sure loops only read or write style properties.

const filtered = [];
// Read
for (i = 0; i < children.length; i++) {
const c = children[i];
if (c.offsetWidth < 1000) {
filtered.push(c)
}
}
let width;
// Write
filtered.forEach((c) => {
width = parseInt(c.style.width, 10);
c.style.width = `${width + 1}%`
});

It is important to note that not every style change will trigger layout re-calculations. For example, changing the background-color will not affect layout.

Run the right time

CSS can do animations, but sometimes we want JS to create animations instead. In these cases, we should start our style calculations at the right time.

doAnimation = () => {
if (animationCompleted) {
clearInterval(interval);
}
// do some styling
}


// assume `doAnimation` will be completed in less than 10ms
interval = setInterval(doAnimation, 10);

doAnimation takes about 10ms out of our allocated 16ms per frame. But what will happen if an iteration of doAnimation will start at the end of the 16ms block?

The answer is: The animation will not complete its calculation by the time the frame should be rendered.

To remedy this, we can ask the browser to fire our doAnimation iteration at the start of the frame block by opting for  requestAnimationFrame, which triggers a callback at the next frame start.

doAnimation = () => {
if (animationCompleted) {
return;
}
// do some styling and schedule to run at next frame
requestAnimationFrame(doAnimation);
}


requestAnimationFrame(doAnimation)

Resources

Guy Y.
Software Developer
Back to Blog