Timers: setTimeout, setInterval and requestAnimationFrame

Run code later or repeatedly, cancel it cleanly, and animate in step with the screen while respecting reduced motion.

  • Beginner
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Schedule and cancel timers
  • Animate with requestAnimationFrame
  • Explain why timer delays are a minimum, not a promise
  • Respect prefers-reduced-motion in script-driven animation

Before you start

Timers let you run code later: hide a message after a few seconds, poll for updates every minute, or move something on screen frame by frame. JavaScript gives you three tools for this. setTimeout runs code once after a delay, setInterval runs it repeatedly, and requestAnimationFrame runs it just before the browser paints the next frame.

setTimeout

setTimeout(callback, delay) runs the callback once, after at least delay milliseconds:

JavaScript
setTimeout(() => {
  console.log("Two seconds later");
}, 2000);

Extra arguments after the delay are passed to the callback:

JavaScript
function greet(name) {
  console.log(`Hello, ${name}`);
}

setTimeout(greet, 1000, "Ada"); // → Hello, Ada (after one second)

Pass a function, not a call. setTimeout(greet("Ada"), 1000) calls greet immediately and passes its return value, undefined, as the callback.

Cancelling with clearTimeout

setTimeout returns an ID. Pass it to clearTimeout to cancel the timer before it fires:

JavaScript
const id = setTimeout(() => console.log("Never logged"), 1000);
clearTimeout(id);

In browsers the ID is a number. In Node.js it is a Timeout object. Either way, treat it as an opaque value and only pass it back to clearTimeout. Calling clearTimeout with an ID that has already fired, or with undefined, does nothing, so it is always safe.

A status message that clears itself

The course dashboard shows a "Saved" message when someone saves a course. It should disappear after a few seconds, and if they save another course quickly, the new message should get its own full display time:

JavaScript
const status = document.querySelector("#status");
let clearStatusTimer;

function showStatus(message, ms = 5000) {
  clearTimeout(clearStatusTimer);
  status.textContent = message;
  clearStatusTimer = setTimeout(() => {
    status.textContent = "";
  }, ms);
}

showStatus("Saved JavaScript Basics");

Without the clearTimeout, the first message's timer would wipe the second message early.

Give people time to read. Messages that vanish after a second or two are missed by slow readers and by anyone who looked away. For anything important, such as an error, leave it visible until the user acts.

The delay is a minimum

The delay is the earliest time the callback can run, not an exact time. A timer callback only runs when the main thread is free. If other code is busy for 500ms, a 100ms timer waits until it finishes:

JavaScript
const start = Date.now();

setTimeout(() => {
  console.log(`Ran after ${Date.now() - start}ms`);
}, 100);

while (Date.now() - start < 500) {
  // busy for half a second
}
// → Ran after 500ms (or slightly more)

Browsers also add limits of their own:

  • Deeply nested timers (a timeout that schedules a timeout, many levels deep) are clamped to a minimum of about 4ms.
  • Timers in background tabs are throttled, often to once per second or less, to save battery.

So never use timers to measure time. If you need to know how much time has passed, compare timestamps from Date.now() or performance.now().

setTimeout(callback, 0) does not run immediately either. It runs after the current code and after any pending promise callbacks, as lessons 31 and 42 explain.

setInterval

setInterval(callback, delay) runs the callback repeatedly, every delay milliseconds, until you stop it with clearInterval:

JavaScript
let count = 0;

const id = setInterval(() => {
  count += 1;
  console.log(`Tick ${count}`);
  if (count === 3) clearInterval(id);
}, 1000);

// → Tick 1
// → Tick 2
// → Tick 3

Do not count ticks to keep time

Because each tick can be late, adding one second per tick drifts away from real time, especially in a background tab. Calculate from a fixed end time instead:

JavaScript
function startCountdown(seconds, onTick, onDone) {
  const endTime = Date.now() + seconds * 1000;

  const id = setInterval(() => {
    const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
    onTick(remaining);
    if (remaining === 0) {
      clearInterval(id);
      onDone();
    }
  }, 250);

  return () => clearInterval(id); // a function the caller can use to stop it
}

const stop = startCountdown(
  10,
  (s) => console.log(`Seconds left: ${s}`),
  () => console.log("Time is up")
);

Checking four times a second and calculating from endTime keeps the display accurate even if some ticks are delayed. Returning a stop function is a tidy way to let the caller cancel the timer without knowing its ID.

If you show a countdown on the page, do not put it in a live region that announces every second. Screen reader users would hear a constant stream of numbers. Announce only meaningful moments, such as "One minute left" and "Time is up".

Repeating with setTimeout

setInterval fires on schedule even if the previous run has not finished its asynchronous work. When each run does something slow, such as a network request, chain timeouts instead, so the next run is only scheduled after the current one completes:

JavaScript
async function poll() {
  try {
    const response = await fetch("data/courses.json");
    if (response.ok) console.log("Checked for updates");
  } catch (error) {
    console.error(error);
  } finally {
    setTimeout(poll, 60_000); // schedule the next check only when this one is done
  }
}

poll();

60_000 is 60000 written with a numeric separator for readability.

Timers as promises

setTimeout uses a callback, but it is easy to wrap in a promise, as lesson 31 did:

JavaScript
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

await delay(1000);
console.log("One second later");

Node.js ships this ready-made in node:timers/promises:

JavaScript
import { setTimeout as sleep } from "node:timers/promises";

await sleep(1000);
console.log("One second later");

requestAnimationFrame

For anything visual that changes every frame, timers are the wrong tool. A screen typically refreshes 60 times a second or more, and a setTimeout(callback, 16) drifts in and out of step with it, producing uneven, juddering movement.

requestAnimationFrame(callback) asks the browser to call your function once, just before it paints the next frame. To animate, the callback requests the next frame itself:

JavaScript
function frame(timestamp) {
  // update something on the page
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

The callback receives a high-resolution timestamp in milliseconds, comparable with performance.now(). Browsers pause requestAnimationFrame in background tabs, so an animation costs nothing while nobody can see it.

Like the other timers, it returns an ID, and cancelAnimationFrame(id) cancels the pending frame.

Base animation on time, not frames

Screens refresh at different rates: 60, 90, 120 times a second or more. If you move something 2 pixels per frame, it moves twice as fast on a 120Hz screen. Instead, work out how far through the animation you are from the elapsed time.

This example counts the dashboard's total lessons up from 0 over one second:

JavaScript
const output = document.querySelector("#total-lessons");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");

function countUp(element, target, duration = 1000) {
  if (reduceMotion.matches) {
    element.textContent = String(target);
    return;
  }

  let start;

  function frame(timestamp) {
    start ??= timestamp;
    const progress = Math.min((timestamp - start) / duration, 1);
    element.textContent = String(Math.round(target * progress));

    if (progress < 1) {
      requestAnimationFrame(frame);
    }
  }

  requestAnimationFrame(frame);
}

countUp(output, 52); // the six courses in data/courses.json have 52 lessons between them
  • start ??= timestamp records the time of the first frame.
  • progress goes from 0 to 1 over duration, whatever the screen's refresh rate.
  • The animation stops itself by not requesting another frame once progress reaches 1.
  • When the user has asked their system to reduce motion, the final value appears straight away.

Always check prefers-reduced-motion before script-driven animation. CSS media queries cannot switch off motion that JavaScript creates frame by frame. Movement can cause dizziness and nausea for people with vestibular disorders, and for anything that moves or updates automatically for more than five seconds, people also need a way to pause or stop it.

For most interface movement, such as fading, sliding and expanding, CSS transitions and animations are simpler and run more efficiently than JavaScript. Use requestAnimationFrame when the values depend on script logic, such as numbers, canvas drawing or positions calculated from data.

Cleaning up

Timers that are never cleared keep their callbacks, and everything those callbacks refer to, alive. When a part of the page is removed, clear its intervals and cancel its animation frames. Lesson 47 shows how forgotten timers turn into memory leaks, and lesson 48 builds debounce and throttle helpers from setTimeout.

Summary

  • setTimeout(fn, ms) runs once; setInterval(fn, ms) repeats. Both return an ID for clearTimeout and clearInterval.
  • Pass a function, not the result of calling one.
  • The delay is a minimum. Busy code, nested timers and background tabs all make timers late.
  • Measure elapsed time with timestamps, not by counting ticks.
  • Chain setTimeout instead of setInterval when each run does slow asynchronous work.
  • requestAnimationFrame runs just before each repaint; base movement on elapsed time and cancel with cancelAnimationFrame.
  • Check prefers-reduced-motion before animating with JavaScript, and clean up timers when their part of the page goes away.

Further reading