Skip to the code
cf.

Interval that corrects its own drift

HardTiming

Timers fire late: a busy page or a background tab can hold a callback back by milliseconds, or by seconds. When each run of a setTimeout chain waits interval ms from when it ran, every delay adds up, and the schedule falls further behind with every tick.

Write accurateInterval(fn, interval), which keeps to a fixed schedule instead. Tick 1 is due interval ms after the call, tick 2 at 2 * interval ms, and tick n at n * interval ms, always counted from the call. Each tick runs as soon as the timers allow once it's due, never before, and a late tick doesn't move the ones after it.

Each tick calls fn(n), where n is the tick's number.

If a timer fires so late that several ticks are due, fn runs once, for the latest of them, and the earlier ones are skipped: it never runs several times in a row to catch up.

It returns a function, stop(), that stops the ticks for good and leaves no timer running. stop() also works when fn calls it.

Use setTimeout, not setInterval: an interval's delay is fixed, so it can't correct itself.

const stop = accurateInterval((n) => console.log("tick", n), 1000);
// ticks are due at 1000, 2000, 3000 ms, and so on, after the call
// at 1004 ms (its timer fired 4 ms late): logs "tick 1"
// at 2001 ms: logs "tick 2", as it's due at 2000 ms, not 1004 + 1000
// the page freezes from 2500 to 5300 ms, then:
// at 5300 ms: logs "tick 5" (ticks 3 and 4 never run)
// at 6000 ms: logs "tick 6"
stop(); // no more ticks

What the tests check

  1. calls fn(n) every interval ms, starting at 1
    const clock = useFakeTimers();
    const fn = spy();
    accurateInterval(fn, 100);
    clock.tick(99);
    expect(fn).toHaveBeenCalledTimes(0);
    clock.tick(1);
    expect(fn).toHaveBeenLastCalledWith(1);
    clock.tick(200);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2, 3]);
    
  2. doesn't drift when every timer fires late
    const clock = useFakeTimers();
    const start = clock.now();
    const fakeSetTimeout = globalThis.setTimeout;
    // Every timer fires 7 ms late, as on a busy page.
    globalThis.setTimeout = (callback, ms, ...args) => fakeSetTimeout(callback, Math.max(0, ms || 0) + 7, ...args);
    const runs = [];
    try {
      accurateInterval((n) => runs.push({ n, at: clock.now() - start }), 100);
      clock.tick(1010);
    } finally {
      globalThis.setTimeout = fakeSetTimeout;
    }
    // Tick n is due at n * 100 ms, so it should run 0 to 7 ms after that: never earlier, and never more than one timer's lateness behind.
    const offSchedule = runs.filter(({ n, at }) => at < n * 100 || at > n * 100 + 7).map(({ n, at }) => "tick " + n + " at " + at + " ms");
    expect(offSchedule).toEqual([]);
    expect(runs.map(({ n }) => n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
    
  3. a late tick doesn't delay the next one
    const clock = useFakeTimers();
    const start = clock.now();
    const fakeSetTimeout = globalThis.setTimeout;
    // The page is busy from 150 to 230 ms: timers due in between run at 230 ms.
    globalThis.setTimeout = (callback, ms, ...args) => {
      const now = clock.now() - start;
      const due = now + Math.max(0, ms);
      return fakeSetTimeout(callback, due >= 150 && due < 230 ? 230 - now : ms, ...args);
    };
    const runs = [];
    try {
      accurateInterval((n) => runs.push(n + " at " + (clock.now() - start)), 100);
      clock.tick(450);
    } finally {
      globalThis.setTimeout = fakeSetTimeout;
    }
    expect(runs).toEqual(["1 at 100", "2 at 230", "3 at 300", "4 at 400"]);
    
  4. skips the ticks that are overdue
    const clock = useFakeTimers();
    const start = clock.now();
    const fakeSetTimeout = globalThis.setTimeout;
    // The page freezes from 150 to 550 ms: timers due in between run at 550 ms.
    globalThis.setTimeout = (callback, ms, ...args) => {
      const now = clock.now() - start;
      const due = now + Math.max(0, ms);
      return fakeSetTimeout(callback, due >= 150 && due < 550 ? 550 - now : ms, ...args);
    };
    const runs = [];
    try {
      accurateInterval((n) => runs.push(n + " at " + (clock.now() - start)), 100);
      clock.tick(750);
    } finally {
      globalThis.setTimeout = fakeSetTimeout;
    }
    expect(runs).toEqual(["1 at 100", "5 at 550", "6 at 600", "7 at 700"]);
    
  5. stop() ends the ticks and leaves no timer
    const clock = useFakeTimers();
    const fn = spy();
    const stop = accurateInterval(fn, 100);
    clock.tick(250);
    stop();
    expect(clock.pending()).toBe(0);
    clock.tick(1000);
    expect(fn).toHaveBeenCalledTimes(2);
    
  6. stop() works from inside fn
    const clock = useFakeTimers();
    let stop;
    const fn = spy((n) => {
      if (n === 3) stop();
    });
    stop = accurateInterval(fn, 100);
    clock.tick(1000);
    expect(fn).toHaveBeenCalledTimes(3);
    expect(clock.pending()).toBe(0);
    
  7. works without setInterval
    const clock = useFakeTimers();
    const fakeSetInterval = globalThis.setInterval;
    globalThis.setInterval = () => {
      throw new Error("accurateInterval must not use setInterval");
    };
    const fn = spy();
    try {
      const stop = accurateInterval(fn, 100);
      clock.tick(350);
      stop();
    } finally {
      globalThis.setInterval = fakeSetInterval;
    }
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2, 3]);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).