Skip to content

Delay and Interval Utilities

Promise-based timing utilities: frame-accurate delay using requestAnimationFrame, random delays for staggered animations, and cancelable frame-synced intervals.

When to Use

  • Waiting a precise duration in async code
  • Animations that need frame-accurate timing
  • Staggering visual effects with random delays
  • Replacing setInterval with frame-synced, cancelable intervals

The Patterns

Simple delay

A promise-based wrapper around setTimeout. Works in any environment:

javascript
await delay(1000);
console.log("1 second elapsed");

Implementing delay()

Wraps setTimeout in a promise so delays can be used with await:

javascript
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Frame-accurate delay

Uses requestAnimationFrame for timing instead of setTimeout, yielding control to the browser between checks:

javascript
await delay(100);
console.log("100ms elapsed");

Why not setTimeout? setTimeout is not frame-synced and can drift. This approach ties delays to the rendering loop, making it ideal for animations.

Implementing frame-accurate delay()

Loops on requestAnimationFrame until the target duration elapses, keeping timing synchronized with the display refresh cycle:

javascript
async function nextFrame() {
  return new Promise(requestAnimationFrame);
}

async function delay(ms = 0) {
  const startTime = performance.now();
  ms = Number(ms);
  while (elapsed() < ms) {
    await nextFrame();
  }
  return ms;
  
  function elapsed() {
    return performance.now() - startTime;
  }
}

Random delay

Stagger effects by picking a random duration within a range:

javascript
for (const element of elements) {
  await randomDelay(50, 150);
  element.classList.add("visible");
}

Implementing randomDelay()

Picks a random target duration within the given range and waits using frame-based timing:

javascript
async function randomDelay(min, max) {
  const startTime = performance.now();
  const target = random(min, max);
  while (elapsed() < target) {
    await nextFrame();
  }
  return target;
  
  function elapsed() {
    return performance.now() - startTime;
  }
}

Simple interval

An async iterable wrapper around setInterval using Repeater. Yields elapsed time at each tick. Cancel by breaking out of the loop:

javascript
for await (const elapsed of interval(1000)) {
  console.log(`${elapsed}ms since start`);
  if (elapsed > 5000) break;
}

Implementing interval() with Repeater

Wraps setInterval in a Repeater async iterable that yields elapsed time on each tick:

javascript
import { Repeater } from "@repeaterjs/repeater";

function interval(ms) {
  const start = performance.now();
  return new Repeater(async (push, stop) => {
    const id = setInterval(() => push(elapsed()), ms);
    await stop;
    clearInterval(id);
  });
  
  function elapsed() {
    return performance.now() - start;
  }
}

Frame-based cancelable interval

Replace setInterval with a frame-synced interval. Returns an object with a cancel() method:

javascript
const timer = interval(1000, (time) => {
  console.log("tick", time);
});

// Stop after 5 seconds
setTimeout(() => timer.cancel(), 5000);

Implementing frame-based interval()

Schedules callbacks aligned to animation frames and returns a { cancel() } handle for cleanup:

javascript
function interval(ms, callback) {
  const start = document.timeline.currentTime;
  let cancelled = false;
  
  function frame(time) {
    if (cancelled) return;
    callback(time);
    scheduleFrame(time);
  }
  
  function scheduleFrame(time) {
    const elapsed = time - start;
    const roundedElapsed =
      Math.round(elapsed / ms) * ms;
    const targetNext =
      start + roundedElapsed + ms;
    const delay = targetNext - performance.now();
    setTimeout(
      () => requestAnimationFrame(frame),
      delay,
    );
  }
  
  scheduleFrame(start);
  
  return { cancel() { cancelled = true; } };
}

Key details

  • performance.now() provides sub-millisecond precision
  • requestAnimationFrame ties execution to the display refresh rate (~16ms at 60fps)
  • document.timeline.currentTime gives a stable reference for elapsed-time calculations
  • Returning a { cancel() } object keeps the API simple — no need to set up an AbortController for basic interval cancellation

Trade-offs

ApproachProsCons
Frame-based delayFrame-synced, smoothHigher CPU overhead
setTimeout promiseSimple, low overheadNot frame-synced, drifts
Frame-based intervalPrecise, cancelableMore complex, browser-only
setIntervalSimple, universalDrifts over time, poor cancel