More Accurate JavaScript Timers with Web Workers
May 20, 2020
JavaScript
Creating timers in JavaScript with setTimeout
or setInterval
is a simple and straightforward process. Yet, the accuracy and reliability of these timers vary. In certain contexts, as when a browser tab or window loses focus, setInterval
and setTimeout
timing can drift, diminishing their accuracy. One reason for this drift is that the JavaScript executes in a single main thread, which shares CPU cycle time with many other processes.
A technique for increasing the accuracy of setTimeout
and setInterval
is executing them in a dedicated thread, separate from the main thread. Most browsers offer a Web Worker API, allowing worker threads to run in the background.
The Problem with Timers
Browsers execute JavaScript in a single thread. This thread has many responsibilities, including listening and responding to user events, updating the UI, etc. Operations, including timing with setTimeout
or setInterval
, share CPU cycles with other tasks in this thread. If the main thread gets blocked or throttled, as when a browser tab loses focus, execution of setTimeout
and setInterval
can lag.
For most applications, a setInterval
or setTimeout
executed in the main thread will suffice–the potential lag is often small and wouldn’t impact the experience. Yet, some scenarios need greater reliability and accuracy.
Case Study: Improving Pomodoro Timer Accuracy
I encountered the issue of timer inaccuracy when working on a Pomodoro timer application. Pomodoro is a productivity technique that involves chunking time into segments of focused effort and unfocused downtime. I created a Pomodoro timer application, Pomotroid, to help track and time these segments. Developed with Electron, it uses a Timer
class powered by setInterval
.
I noticed that when minimized or hidden, the application timer would lag behind the system clock. Sometimes this lag was only a few seconds, while at other times it could be up to 10 minutes! When a timer completed, it would kick off a new timer. So, a lag of a few milliseconds compounded over time, producing an inconsistent experience. This posed a serious problem as the application’s primary role is to help users keep track of their time.
Moving the execution of the Timer
to a web worker increased its accuracy and reliability. Thanks to a dedicated worker thread, even when minimized or hidden, the timer maintains accuracy over periods of several hours.
Timers in Web Workers
The Web Worker API allows code execution in a thread separate from the main thread. Web workers have a limited set of APIs they can access. For example, you won’t have access to the DOM, but you’re still able to access things like setInterval
and setTimeout
.
Using web workers is simple. It involves creating a separate JavaScript file, whose contents run in the worker. Following is a simple example of a timer in a web worker.
First, we need a file with some code to run in the worker. In this file, we’ll set up a simple setTimeout
function that logs the difference between the starting and ending times.
// timer-worker.js
const start = performance.now()
setTimeout(() => {
console.log(performance.now() - start)
}, 1000 * 60 * 10) // 10 minutes
We’ll create another file to serve as our entry point. In this file, we’ll instantiate a new worker, passing the location of the worker file. We’ll also start another timer here for comparison. Now, when this script loads, a new worker will spawn and its code executed. The worker thread will continue running, even when the tab loses focus or the main thread slows.
// main.js
;(function() {
const worker = new worker('timer-worker.js')
const start = performance.now()
setTimeout(() => {
console.log(performance.now() - start)
}, 1000 * 60 * 10) // 10 minutes
})()
Main and Worker Thread Comparisons
We can build on the previous example to illustrate the difference between running a timer on the main thread and in a worker.
The following table represents the results when the browser tab has focus (little to no throttling).
Thread | Time (ms) | Delta Time (ms) | Lag (ms) |
---|---|---|---|
Main | 600000 |
600001.5600000042 |
1.56 |
Worker | 600000 |
600000.6300000241 |
0.63 |
Main | 900000 |
900001.4249999658 |
1.42 |
Worker | 900000 |
900000.6799999974 |
0.68 |
Main | 1800000 |
1800001.0650000186 |
1.07 |
Worker | 1800000 |
1800000.5450000172 |
0.55 |
Main | 3600000 |
3600001.489999995 |
1.49 |
Worker | 3600000 |
3600000.2250000252 |
0.23 |
The following table represents the results when the browser tab does not have focus (throttling of the main thread).
Thread | Time (ms) | Delta Time (ms) | Lag (ms) |
---|---|---|---|
Main | 600000 |
600511.5100000112 |
511.51 |
Worker | 600000 |
600001.0100000072 |
1.01 |
Main | 900000 |
900412.8750000091 |
412.88 |
Worker | 900000 |
900001.1800000211 |
1.18 |
Main | 1800000 |
1800897.6649999968 |
897.66 |
Worker | 1800000 |
1800001.2699999788 |
1.27 |
Main | 3600000 |
3600403.105000034 |
403.11 |
Worker | 3600000 |
3600002.4100000155 |
2.41 |
The results from these tests suggest a notable difference between the two approaches. When a tab is active and the main thread isn’t throttled, both the main thread and worker timers have a negligible delay. But when a tab loses focus and the main thread slows, the worker timer outperforms the main thread timer.
The source for this demo is available on GitHub.
Closing Thoughts
Executing timers in web workers can improve their accuracy, but this approach isn’t a silver bullet. There’s still no guarantee that setInterval
and setTimeout
won’t lag. If you need precise calculation between times, you may be better off using Date objects or the Performance interface. But for most cases, pairing a web worker with setInterval
and setTimeout
can increase the reliability and accuracy of timers.