JavaScript Timers & Intervals
Tags: webdev, javascript, programming
Published: 2023-12-04 01:24:05

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

## The Timer Identity

```javascript
setTimeout(doSomething, 1000);
```

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](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified).

## 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](https://developer.mozilla.org/en-US/docs/Web/API/window/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](https://en.wikipedia.org/wiki/Spectre_(security_vulnerability)) 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](https://github.com/w3c/hr-time/issues/56) 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](https://runjs.app) 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. 

```javascript
// 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);
```

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

```javascript
[
  { 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' },
]
```

#### Chrome

```javascript
[
  { 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' },
];
```

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

```javascript
[
  { 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', },
];
```

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

```javascript
[
  { 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' },
];
```

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:

```javascript
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' }
]
```

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](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers), 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.

```javascript
// We only care about seconds
setInverval(() => {
  clockDiv.innerText = (new Date()).getSeconds();
}, 1000);
```

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](https://gist.github.com/oculus42/68f7058ec5a852ab4ea2b05839faf302) to improve our odds. With a simple function reporting the current second and timestamp we can hopefully see some bugs!

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

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

```javascript
[
  { 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
];
```

Firefox drifts until we miss a second.

```javascript
[
  { 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 },
];
```

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**.

```javascript
[
  { 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 },
]
```

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](https://developers.google.com/speed/docs/insights/browser-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.

```javascript
// 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} },`);
};
```

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

```javascript
[
  { second: 46, timestamp: 86900 },
  { second: 47, timestamp: 87801 },
  { second: 48, timestamp: 88699 },
  { second: 49, timestamp: 89599 },
  { second: 50, timestamp: 90501 },
]
```

### 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.

```javascript
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);
};
```

Let's see how things change with this timer.

#### Firefox Output

```javascript
[
  { 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' },
]
```

#### Safari Output (macOS)

```javascript
[
  {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'},
]
```

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.

```javascript
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);
};
```

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.