DEV Community

Cover image for JavaScript Timers & Intervals
Samuel Rouse
Samuel Rouse

Posted on

JavaScript Timers & Intervals

We expect a 1000 millisecond timer will run in one second. That's not always the case.

The Timer Identity

setTimeout(doSomething, 1000);
Enter fullscreen mode Exit fullscreen mode

This code clearly says it will doSomething in one second (1000 milliseconds). However, it will actually run as soon as it can after that amount of time has passed. It's not a guarantee, and there are many reasons it could be delayed.

The Timer Reality

This happens with setTimeout, setInterval, and any other timed or recurring action. In fact, recognizing and accounting for variation is so important that requestAnimationFrame provides the timestamp of when the animation frame started as an argument to the callback function. The examples on MDN all use an initial timestamp comparison to calculate timing and ensure smooth movement or animation. As they say, timing is everything.

Old Injuries Slow You Down

In January 2018, the world was introduced to speculative execution attacks that affected nearly all modern computers and devices. It was soon discovered that JavaScript's high-resolution timers allowed these attacks to be executed from within a browser simply by visiting a web site. In response, browser manufacturers implemented timer precision limits. Safari now reports precision only to 1 ms, while Chrome reduced the precision to 100 µs (microseconds) and added 100 µs of jitter, which introduces randomized variation.

If you're interested, you can refer to this W3C GitHub issue for high-resolution timers for additional links and discussions about the issue.

So when running tests, remember these precision limitations. Safari will only show variation if the time is more than 1 ms off, and Chrome may be off by 0.2 ms from when a function actually runs.

However, computers are very fast, so are these concerns unnecessary? Let's find out by using setInterval to test their consistency.

Variations On An Interval

I used RunJS to design and test this code. I ran the same code in different browsers, but the output needed manual formatting to match.

Test Code

The code to test timer variation is not particularly pretty, but it breaks down into a few pieces. The intervalFor function wraps setInterval so that the test will eventually end. It also executes our report function as a callback. The test function collects and formats our data.

When I tested different browsers the minimum change was 0.1 ms, so I used .toFixed(1) to better represent the precision.

// Run an action every delay for a period of time
const intervalFor = (period, delay, action, callback) => {
  const id = setInterval(action, delay);
  setTimeout(() => {
    clearInterval(id);
    callback();
  }, period);
};

// Collect and report delay variation
const test = (period, delay) => {
  const records = [performance.now()];
  const addRecord = () => records.push(performance.now());
  const formatter = (v, i, a) => ({
    baseVariation: ((v - a[0]) % delay).toFixed(1),
    prevGap: (v - (a[i - 1] || a[0])).toFixed(1),
  });
  const report = () => console.log(records.map(formatter));
  intervalFor(period, delay, addRecord, report);
};

// Run for 1000 ms with a 100 ms increment.
test(1000, 100);
Enter fullscreen mode Exit fullscreen mode

It may seem over-engineered, but the test closure isolates timers to allow overlapping runs. Now, let's look at some output!

Test Output

RunJS

[
  { baseVariation: '0.0', prevGap: '0.0' },
  { baseVariation: '4.6', prevGap: '104.6' },
  { baseVariation: '0.1', prevGap: '95.5' },
  { baseVariation: '3.4', prevGap: '103.3' },
  { baseVariation: '2.2', prevGap: '98.8' },
  { baseVariation: '5.6', prevGap: '103.4' },
  { baseVariation: '0.3', prevGap: '94.7' },
  { baseVariation: '3.7', prevGap: '103.4' },
  { baseVariation: '2.2', prevGap: '98.5' },
  { baseVariation: '0.7', prevGap: '98.5' },
]
Enter fullscreen mode Exit fullscreen mode

Chrome

[
  { baseVariation: '0.0', prevGap: '0.0' },
  { baseVariation: '5.5', prevGap: '105.5' },
  { baseVariation: '5.7', prevGap: '100.2' },
  { baseVariation: '5.9', prevGap: '100.2' },
  { baseVariation: '5.2', prevGap: '99.3' },
  { baseVariation: '1.5', prevGap: '96.3' },
  { baseVariation: '4.2', prevGap: '102.7' },
  { baseVariation: '8.7', prevGap: '104.5' },
  { baseVariation: '1.7', prevGap: '93.0' },
  { baseVariation: '1.8', prevGap: '100.1' },
];
Enter fullscreen mode Exit fullscreen mode

Chrome (RunJS also uses Chrome's V8 engine) shows fluctuations between intervals, attempting to compensate for late calls and stay close to the original interval timing, but never running early. However, the variation between ticks can be significant, up to 7.3 ms beyond the expected interval, and up to 8 ms later than anticipated. These variations are well outside the precision limits.

Firefox

[
  { baseVariation: '0.0', prevGap: '0.0', },
  { baseVariation: '8.0', prevGap: '108.0', },
  { baseVariation: '13.0', prevGap: '105.0', },
  { baseVariation: '16.0', prevGap: '103.0', },
  { baseVariation: '17.0', prevGap: '101.0', },
  { baseVariation: '22.0', prevGap: '105.0', },
  { baseVariation: '28.0', prevGap: '106.0', },
  { baseVariation: '29.0', prevGap: '101.0', },
  { baseVariation: '32.0', prevGap: '103.0', },
  { baseVariation: '35.0', prevGap: '103.0', },
];
Enter fullscreen mode Exit fullscreen mode

Firefox never hits the exact interval and, unlike Chrome, it doesn't shorten intervals to align better with the original time. It consistently drifts from the expected interval timing.

Safari

[
  { baseVariation: '0.0', prevGap: '0.0' },
  { baseVariation: '1.0', prevGap: '101.0' },
  { baseVariation: '1.0', prevGap: '100.0' },
  { baseVariation: '7.0', prevGap: '106.0' },
  { baseVariation: '7.0', prevGap: '100.0' },
  { baseVariation: '8.0', prevGap: '101.0' },
  { baseVariation: '8.0', prevGap: '100.0' },
  { baseVariation: '8.0', prevGap: '100.0' },
  { baseVariation: '8.0', prevGap: '100.0' },
  { baseVariation: '8.0', prevGap: '100.0' },
];
Enter fullscreen mode Exit fullscreen mode

Safari provides a more predictable interval but drifts like Firefox, only more slowly.

Different Intervals

All the tests above used 100 ms intervals. Let's try a shorter test and see how it performs:

test(10, 1);

[
  { baseVariation: '0.0', prevGap: '0.0' },
  { baseVariation: '0.3', prevGap: '1.3' },
  { baseVariation: '0.3', prevGap: '1.0' },
  { baseVariation: '0.3', prevGap: '1.0' },
  { baseVariation: '0.3', prevGap: '1.0' },
  { baseVariation: '0.1', prevGap: '4.8' },
  { baseVariation: '0.4', prevGap: '3.3' }
]
Enter fullscreen mode Exit fullscreen mode

We expected ten cycles but we only collected six – the first row represents the "before" data. The additional delays between cycles accumulated and we reached the end of the test period before more cycles could run. It's important to note that exact timing is not guaranteed, which can affect the cycle count as well!

The sudden increase in time for this specific test is due to the HTML Timers spec, which limits nested timers to running no more than four times below 4 ms.

Another Consideration

The timing will also depend on the computing ability your device and the number of other tasks it is performing. I ran each test on macOS and Windows (excluding Safari), and the results were similar. Microsoft Edge produced results similar to Chrome. At the time of writing, the Arc browser has an out-of-spec Array.prototype.map implementation in the console, so I couldn't test it without changing the code, and therefore, it was not included in the results.

Lessons

These simple examples show significant variations across different browsers. Although this sample doesn't cover multiple runs, it demonstrates that timing works differently in different browsers.

Combatting Accommodating Variation

We cannot fix the timers and intervals, or control the browsers and devices our customers use. However, being aware of these variations allows us to write code that works within these limitations.

Let's look at a few possible solutions.

YAGNI

"You Aren't Gonna Need It"

The first question to ask is, "Does this variation impact our project?" Many times the answer is no, and we can stop there. For example, does it matter if our 100 ms delay is actually 108 ms? No, because we don't need to build special logic for most small variations. We aren't going to need it.

However, if your web app is measuring operations in 100 millisecond groups, variations of 15% could significantly affect your results.

Reducing Intervals

A reliable way to ensure something happens on time is...check more often. This might be annoying when a person does it — "Has it been a minute yet?" – but it can be helpful in programming.

Any solution needs to accommodate jitter in Chromium browsers and drift in Firefox and Safari. The key is deciding on an interval.

The Clock is Ticking

Let's think about a very simple clock or timer. Our "clock" only shows the seconds portion of the time and updates roughly every second.

// We only care about seconds
setInverval(() => {
  clockDiv.innerText = (new Date()).getSeconds();
}, 1000);
Enter fullscreen mode Exit fullscreen mode

With this code timer variations could cause us to miss a second, but seeing a timer error can depend as much on when in the second the code runs as the stability of the interval. Firefox and Safari will drift and eventually skip a second, but Chrome's jitter might never show an issue if the code "away from the edges" of a second.

Starting a timer near the end of a second makes errors more visible, so I wrote a small utility to improve our odds. With a simple function reporting the current second and timestamp we can hopefully see some bugs!

const updateClock = () => {
  const currentTime = (new Date()).getSeconds();
  console.log(` { second: ${currentTime}, timestamp: ${Date.now() % 100000} },`);
};
Enter fullscreen mode Exit fullscreen mode

Chrome's jitter is very obvious when the timer starts at the wrong moment.

[
  { second: 17, timestamp: 37998 },
  { second: 18, timestamp: 38998 },
  { second: 20, timestamp: 40000 }, // 19 Missed
  { second: 21, timestamp: 41001 },
  { second: 22, timestamp: 42000 },
  { second: 22, timestamp: 42998 }, // 22 Twice
  { second: 23, timestamp: 43999 },
  { second: 25, timestamp: 45001 }, // 24 Missed
  { second: 25, timestamp: 45998 }, // 25 Twice
  { second: 27, timestamp: 47000 }, // 26 Missed
];
Enter fullscreen mode Exit fullscreen mode

Firefox drifts until we miss a second.

[
  { second: 17, timestamp: 37999 },
  { second: 18, timestamp: 38999 },
  { second: 20, timestamp: 40003 }, // No 19
  { second: 21, timestamp: 41009 },
  { second: 22, timestamp: 42013 },
  { second: 23, timestamp: 43027 },
  { second: 24, timestamp: 44026 },
  { second: 25, timestamp: 45030 },
  { second: 26, timestamp: 46036 },
];
Enter fullscreen mode Exit fullscreen mode

Safari has the same issue but the drift is slower.

Accuracy & Precision

If we can't depend on 1000 ms, what is the right interval? At 900 ms we should never miss a second, but that creates the possibility of 1.8 seconds between clock updates.

[
  { second: 45, timestamp: 45399 },
  { second: 46, timestamp: 46300 },
  { second: 47, timestamp: 47200 },
  { second: 48, timestamp: 48097 },
  { second: 48, timestamp: 48996 }, // 48 Twice
  { second: 49, timestamp: 49901 },
]
Enter fullscreen mode Exit fullscreen mode

The clock never misses a second and is always accurate when it updates, but the clock is not precise about updating when the new second starts. Also, a user will see this "lag" happen roughly every ten seconds.

Both accuracy and precision are important in a clock. We can improve our precision with a smaller interval.

Using 100 milliseconds should make our clock precise to under 0.15 seconds, accounting for either drift or jitter. Our timer runs more often, but the output appears to update regularly and reliably to most observers.

Limit Processing

It is important to be more mindful of the processing demands our code makes when we reduce the interval. For instance, updating the DOM can be expensive, especially if it causes reflow. If we are running an expensive operation or causing DOM changes, we can ensure it only runs when it needs to cause a change rather than every interval.

// A reference for our timer
let lastUpdate;

const updateClock = () => {
  // Make sure we only operate once per second
  const currentTime = (new Date()).getSeconds();

  // Leave early if the second hasn't changed
  if (lastUpdate === currentTime) return;

  // Time to update! Track the new time
  lastUpdate = currentTime;  

  // Do the work here
  console.log(` { second: ${currentTime}, timestamp: ${Date.now() % 100000} },`);
};
Enter fullscreen mode Exit fullscreen mode

Now even though our code runs more often, it only outputs once a second.

[
  { second: 46, timestamp: 86900 },
  { second: 47, timestamp: 87801 },
  { second: 48, timestamp: 88699 },
  { second: 49, timestamp: 89599 },
  { second: 50, timestamp: 90501 },
]
Enter fullscreen mode Exit fullscreen mode

Build Your Own Interval

While we can't directly compensate for the jitter of Chrome, we can attempt to improve the drift of Firefox and Safari.

const manualInterval = (functionRef, delay, ...args) => {
  const intervalStart = Date.now();
  let timerId;
  const nextTime = () => {
    const nextInterval = delay - ((Date.now() - intervalStart) % delay);
    timerId = setTimeout(nextTime, nextInterval);
    functionRef(...args);
  };

  setTimeout(nextTime, delay)
  return () => clearTimeout(timerId);
};
Enter fullscreen mode Exit fullscreen mode

Let's see how things change with this timer.

Firefox Output

[
  { baseVariation: '0.0', prevGap: '0.0' },
  { baseVariation: '5.0', prevGap: '105.0' },
  { baseVariation: '4.0', prevGap: '99.0' },
  { baseVariation: '3.0', prevGap: '99.0' },
  { baseVariation: '4.0', prevGap: '101.0' },
  { baseVariation: '9.0', prevGap: '105.0' },
  { baseVariation: '3.0', prevGap: '94.0' },
  { baseVariation: '3.0', prevGap: '100.0' },
  { baseVariation: '14.0', prevGap: '111.0' },
  { baseVariation: '6.0', prevGap: '92.0' },
]
Enter fullscreen mode Exit fullscreen mode

Safari Output (macOS)

[
  {baseVariation: '0.0', prevGap: '0.0'},
  {baseVariation: '1.0', prevGap: '101.0'},
  {baseVariation: '1.0', prevGap: '100.0'},
  {baseVariation: '1.0', prevGap: '100.0'},
  {baseVariation: '1.0', prevGap: '100.0'},
  {baseVariation: '1.0', prevGap: '100.0'},
  {baseVariation: '1.0', prevGap: '100.0'},
  {baseVariation: '1.0', prevGap: '100.0'},
  {baseVariation: '0.0', prevGap: '99.0'},
  {baseVariation: '1.0', prevGap: '101.0'},
  {baseVariation: '0.0', prevGap: '99.0'},
]
Enter fullscreen mode Exit fullscreen mode

It's not perfect, but it can reduce the inconsistency if maintaining the approximate interval is important.

Counting Cycles

If we need to run a piece of code ten times over the course of a minute, we have to consider the possibility that timer variation could cause it to only run nine times, and we have to step back and look at our requirements. Is it more important to run a piece of code ten times or that all of the function calls happen within a minute? If the answer is the count not the period, we might need to write something to deal with cycles. Here is a very quick example function for running an interval for a number of cycles rather than a period of time.

const intervalCycles = (cycles, functionRef, delay, ...args) => {
  let cycle = 1;
  const checkCycle = () => {
    functionRef(...args);
    cycle += 1;
    if (cycle > cycles) clearInterval(id);
  }
  const id = setInterval(checkCycle, delay);
};
Enter fullscreen mode Exit fullscreen mode

If both cycles and period are critical, this could also be combined with reducing the interval.

Last Words

Timers aren't perfect, so it's important we know about the limitations of our tools and environments. This knowledge helps us look past our code to our goals and identify the actual requirements for our projects. As with many complex details of programming, know these problems exist and how to research them can be more important than trying to memorize all the solutions up front.

Understanding the difference between an expected design and the reality of an implementation can help us quickly identify defects and, sometimes, prevent them altogether.

Top comments (1)

Collapse
 
best_codes profile image
Best Codes

Whew! That's good to know, although a bit sad to hear. I like my numbers to be precise. Thanks for writing!