Sluggish Animations On Purpose Part 2
In the previous article, we found a solution with CSS. In this article, we want to solve it with JavaScript.
Project
In a previous article, we built a project with a sluggish animation. The animation is a square going from the top left to the bottom right corners of a box.
There is a button that triggers a long and CPU-intensive calculation in Javascript, making the animation sluggish.
In the previous article, we found a solution with CSS. In this article, we want to solve it with JavaScript.
Problem
The problem is a calculation that takes a long time to finish. The specific calculation is:
const getHighNumber = () => {
let x = BigInt(0);
for (let i = BigInt(0); i < BigInt(1_000_000); i++) {
x += i * i;
}
return x;
};
Which we trigger on click of the button:
document.getElementById('sluggish').addEventListener('click', () => {
console.log('start');
const x = getHighNumber();
console.log('end', x);
});
We get a sluggish animation because Javascript has only one thread, and the browser uses the same thread to render the UI.
Solutions
Let’s solve this sluggish animation by improving the JavaScript part. I can think of different possible solutions:
Improving the performance of the code.
Send it to the background with a Promise.
Use a Service Worker.
Split the task into smaller ones.
Improve Performance
This solution should be the first you want to try. If you can find a more performant solution where you don’t need anything else, use it. It’s cleaner than using Promises, Service Workers, or splitting the work.
Improving performance should be the priority.
Yet, be careful with the performance. Maybe it’s performant enough on a new computer, but many users access websites with smartphones that are not powerful. Therefore, the new algorithm needs to be performant for all users, not just for the developer with the fancy computer.
This solution depends a lot on the problem. In this case, I don’t want to solve it like this because I want to practice the other techniques.
Send to Background
When we fetch things, the browser makes a request and executes our callback when it has finished. Can we use something like this? Send the calculation with a Promise
that returns the value?
Let’s change the code from synchronous to asynchronous:
const getHighNumberAsync = async () => {
return new Promise((resolve) => {
let x = BigInt(0);
for (let i = BigInt(0); i < BigInt(5_000_000); i++) {
x += i * i;
}
resolve(x);
});
};
// Notice the `async` function
document.getElementById('sluggish').addEventListener('click', async () => {
console.log('start');
// “send” calculation to the background
const x = await getHighNumberAsync();
console.log('end', x);
});
The animation is still sluggish. Why is that?
Using asynchronous code didn’t send the calculation behind the scenes. Instead, it delayed the execution by sending it first to the background and then immediately back to the call stack. The computation is still done in the call stack, which runs on one thread and blocks the animation.
Promises are not a magic tool to send any task to the background.
Service Worker
There is a possibility of using more than one thread in the browser, and this is by using a Service Worker. We can install one and send the task there while waiting for the reply.
// FRONTEND CODE
const worker = new Worker("./sw.js", { type: "module" });
// Event on click
document.getElementById('sluggish').addEventListener('click', async () => {
console.log('start');
worker.postMessage('calculate');
});
// This is how we receive data from the Service Worker
worker.addEventListener("message", ({ data }) => {
console.log('end', data);
});
// INSIDE THE SERVICE WORKER
const getHighNumber = () => {
let x = BigInt(0);
for (let i = BigInt(0); i < BigInt(1_000_000); i++) {
x += i * i;
}
return x;
};
self.addEventListener("message", async () => {
const result = getHighNumber();
// This is how we send data
// from the Service Worker to the Frontend code
postMessage(result);
});
Clicking the button now sends a message to the Worker. On another side, we listen to events and messages coming from the Worker. This is similar to waiting for a user event like a “click.”
Service Worker works because they run in another thread by definition:
“A service worker [...] runs on a different thread to the main JavaScript that powers your app, so it is non-blocking.” MDN Docs
Split into Smaller Tasks
Browsers render the UI of the page sixty times a second. The static content between each update is called a frame. The animation becomes sluggish when the browser can’t finish the computation of the next render in time for the next frame. Therefore, if we split the calculation, leaving enough time for the browser to render the UI every frame, we won’t have a sluggish animation.
The first idea that comes to mind is to use a “setInterval.” We solve a part in each call until the calculation is finished, and then we remove the interval.
Something like the following:
let isCalculated = false;
let result = BigInt(0);
let currentIndex = BigInt(0);
let splitSize = BigInt(10_000);
let totalSize = BigInt(1_000_000);
const getPartialHighNumber = () => {
let i;
for (i = currentIndex; i < currentIndex + splitSize; i++) {
result += i * i;
// Calculation has finished, we exit loop.
if (i >= totalSize) {
isCalculated = true;
return;
}
}
currentIndex = i;
};
const getHighNumber = () => {
let id = setInterval(() => {
// Check wether result is already calculated
if (isCalculated) {
console.log('end', result);
clearInterval(id);
} else {
getPartialHighNumber();
}
}, 16);
};
document.getElementById('sluggish').addEventListener('click', async () => {
console.log('start');
// “getHighNumber” does not return anything now
// it sets the interval.
getHighNumber();
});
In this case, clicking the buttons sets an interval that calls a function that partially solves the problem and accumulates the result. When the final solution is reached, we remove the interval.
Splitting the calculation leaves time for every frame to finish rendering the animation.
Instead of the “setInterval,” we can also use “requestAnimationFrame.” Even though it’s supposed to be used for animations, we can use the function called on every frame to partially solve the problem, just like we did inside the “setInterval.”
Conclusion
We found two possible solutions in JS to our sluggish animation: send the calculation to a Service Worker or split the work. I don’t think there is a perfect solution. Most of the time, software engineering is about trade-offs and requirements.
Don’t learn only the answers; understand the principles and the reasoning behind the solutions to adapt them to your projects.
If you like this post, consider sharing it with your friends on twitter or forwarding this email to them 🙈
Don't hesitate to reach out to me if you have any questions or see an error. I highly appreciate it.
And thanks to Michal for reviewing this article 🙏