Websites have become so much more interactive, animated and exciting, and users have come to expect super smooth experiences. Frame drops upset visitors and leave them with a poor impression, but what happens if we have a process to run that takes more than 16m/s? Here's what I do...
I've constantly wrestled with the challenge of "when to do job X?" and I'm betting you have too. If we have a dynamic and interactive website or application then we need to ensure that the user doesn't get plagued with glitches during animations or scrolling. If you are writing a game it's an ongoing battle, but these days it's everywhere as even the simplest sites want to show off some cool stunts.
setTimeout(someFunctionThatCausesJank, 100);
The first resort is perhaps to try the line above, we know that
setTimeout
queues the function and we can try to guess at how long the currently running animation will take to complete. It's a real guess though, and as our web apps will run on everything from high-end desktops to mobile phones, we probably won't get it right for everyone.What I really want to do is say "Run this function when you are not busy" and it turns out that there is a way to do that.
will call a function the next time the system is idle and furthermore it will tell you how much time is remaining!requestIdleCallback
This makes it super easy to write a function that will work like setTimeout but now we can ask the system to "run this in idle when you've got this much time free!"
Here is an example of such a function in TypeScript:
interface RequestApi {
timeRemaining(): number
}
declare global {
interface Window {
requestIdleCallback(fn: (api: RequestApi) => void): number
}
}
export declare type VoidFunction = (...params: any[]) => void
export function runWhenIdle<T extends VoidFunction>(
fn: T,
ms: number = 14.5,
maxMs?: number
) {
const start = Date.now()
function run(api: RequestApi) {
if (
api.timeRemaining() < ms &&
(!maxMs || Date.now() - start < maxMs)
) {
window.requestIdleCallback(run)
} else {
fn()
}
}
ms = Math.min(14.7, Math.max(0, ms))
window.requestIdleCallback(run)
}
export function idleFunction<T extends VoidFunction>(
fn: T,
ms: number = 14.5,
maxMs?: number
): T {
const result: unknown = function (...params: any[]) {
runWhenIdle(() => fn(...params), ms, maxMs)
}
return result as T
}
Here's that code in Javascript:
export function runWhenIdle(fn, ms = 14.5, maxMs) {
ms = Math.min(14.7, Math.max(0, ms))
window.requestIdleCallback(run)
const start = Date.now()
function run(api) {
if (
api.timeRemaining() < ms &&
(!maxMs || Date.now() - start < maxMs)
) {
window.requestIdleCallback(run)
} else {
fn()
}
}
}
export function idleFunction(fn, ms = 14.5, maxMs) {
return function (...params) {
runWhenIdle(()=>fn(...params), ms, maxMs)
}
}
What I've done here is to create two very useful functions.
runWhenIdle
takes a function and the number of milliseconds that should be available in the current frame before the function is run (with an optional timeout that gives the maximum wait before any idle time is enough to run).idleFunction
is a higher-order function that wraps a function and returns one that can be called and will wait until the specification is complete before executing the supplied action.// Run when there's pretty much a whole frame free
runWhenIdle(someFunctionThatCausesJank)
//Run when there's 6 m/s free or after 1 second
runWhenIdle(someFunctionThatCausesJank, 6, 1000)
//Create a wrapped version of a function
const doSomething = idleFunction(someFunctionThatCausesJank, 7)
...
//Call someFunctionThatCausesJank with these parameters when there is 7m/s free in idle
doSomething("Hello", 1)
Let's have a look at this all in action. In the demo below you can see we are pretty busy with animations!
First of all, try clicking "Update count when really idle" a bunch of times. Maybe your count in the title will go up, but probably not.
Next click "Halve list when 5m/s free" a few times, suddenly all of those queued up "Update counts" will kick in and it will fly :)
Now clicking "Update count" will start working more readily, if you remove most of the animations it will be instant.
Click "Restart" to try again.
The above code does solve the problem of "when to start something" and I use this technique all the time to schedule things like CSS-in-JSS stylesheet clean up etc, but if the process we are running takes a long time then the system will lose interactivity. Try clicking "Do something slow" above for an example of a long sort operation.
What we want to do here is to split a job up and run it over several frames, using whatever idle time we can find available.
We can break up a process by writing an async function and then using
await new Promise(resolve=>setTimeout(resolve))
which will split our code up across frames but it's a very blunt instrument as we need to guess when the code should be split. Poor guesses mean that the whole thing either takes longer than it should or still causes a glitch, but this is quick to implement and can be a useful technique in some circumstances.If your code includes JSON parsing, sorts, list searches, compression, and the like then to really avoid Jank you also need to consider writing your own version of all of these functions that distribute the load over multiple frames.
Fortunately for you - I really have this problem and need to perform all sorts of operations in JS while animating and scrolling so I combined the technique of using
requestIdleCallback
with generator functions and reference implementations of most of the standard long-running operations and produced an MIT licensed npm package which has documentation and examples here: js-coroutines. This package uses most of the idle time on each frame to run your code or some of the specially adapted standard functions, keeping animations and scrolling smooth while getting to the answer as fast as possible.You can click "Do something smooth" above to watch the animations stay silky smooth while a million records are sorted. Many more demos are available on the packages page.
js-coroutines also has polyfills for
requestIdleCallback
meaning it works in NodeJS and you can benefit in React Native. In this article we've discussed a number of techniques to reduce or totally remove jank from your web app or site:
setTimeout
to delay a heavy function until after animations are complete requestIdleCallback
to delay until there is an amount of idle time available (with some useful helper functions you might want to use in your own code)await new Promise(resolve=>setTimeout(resolve))
to defer the next part of a process until the next time the main loop runsWhether you need to bother with the whole js-coroutines system or you can get away with the simple
runWhenIdle
principles presented here, I hope this article has given you the tools to improve the slickness of your site or App!