Timers & Animation Frames

JavaScript · Lesson 38 of 61 6 min read Updated

Some code should not run right now. A message should disappear after three seconds, a countdown should tick every second, a search should wait until the user stops typing, and a ball should glide across the screen. JavaScript has three tools for running code later: setTimeout, setInterval and requestAnimationFrame.

In this lesson you will use all three, learn how to cancel them, and see two patterns that real apps use every day: a countdown and a debounced search box.

setTimeout: run once, later

setTimeout(callback, delay) runs the callback once, after at least delay milliseconds (1,000 ms is one second). Any extra arguments after the delay are passed to the callback. It returns a timer ID, a number you can pass to clearTimeout() to cancel the timer before it fires.

JavaScriptRun it yourself
console.log("Order placed");

setTimeout(() => {
  console.log("Your chai is ready!");
}, 2000);

setTimeout((name, table) => {
  console.log(`${name}, table ${table} is ready`);
}, 1000, "Priya", 7);

const reminder = setTimeout(() => {
  console.log("This line never prints");
}, 1500);
clearTimeout(reminder);

console.log("Waiting...");
Output
Order placed
Waiting...
Priya, table 7 is ready
Your chai is ready!

Look at the order. "Waiting..." prints second, even though it is the last line. setTimeout does not pause the program: it only schedules the callback and returns immediately. Your code carries on, and the callback runs later. You will see exactly how this works in How Asynchronous Code Works.

The delay is a minimum, not a promise. A timer cannot interrupt code that is already running, so if the page is busy, the callback waits. Browsers also slow timers down in background tabs to save battery. Even setTimeout(fn, 0) waits until the current code has finished.

setInterval: repeat until cancelled

setInterval(callback, delay) runs the callback again and again, every delay milliseconds, until you call clearInterval() with its ID.

JavaScriptRun it yourself
let overs = 0;

const intervalId = setInterval(() => {
  overs++;
  console.log(`Over ${overs} bowled`);

  if (overs === 3) {
    clearInterval(intervalId);
    console.log("Drinks break!");
  }
}, 500);
Output
Over 1 bowled
Over 2 bowled
Over 3 bowled
Drinks break!

An interval that is never cleared runs until the page closes. Always keep the ID and decide when the interval should stop.

A countdown that stays accurate

Because each tick can arrive a little late, counting ticks slowly drifts away from real time. A better countdown remembers when it should end and calculates the time left on each tick from the clock with Date.now():

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Resend countdown</title>
</head>
<body>
  <p>We sent a 6-digit code to your phone.</p>
  <button type="button" id="resend" disabled>Resend code</button>
  <span id="wait"></span>

  <script>
    const button = document.querySelector("#resend");
    const wait = document.querySelector("#wait");
    let timerId = null;

    function startCountdown(seconds) {
      const endsAt = Date.now() + seconds * 1000;
      button.disabled = true;

      function tick() {
        const left = Math.ceil((endsAt - Date.now()) / 1000);
        if (left <= 0) {
          clearInterval(timerId);
          button.disabled = false;
          wait.textContent = "";
          return;
        }
        wait.textContent = `You can resend in ${left}s`;
      }

      clearInterval(timerId); // never run two countdowns at once
      tick();                 // show the first number straight away
      timerId = setInterval(tick, 250);
    }

    button.addEventListener("click", () => {
      wait.textContent = "Code sent again.";
      startCountdown(10);
    });

    startCountdown(10);
  </script>
</body>
</html>

Two habits in this code are worth copying. It calls clearInterval before starting a new interval, so clicking quickly can never create two countdowns fighting each other. And it ticks every 250 ms but displays whole seconds, so the number changes close to the real second boundary.

Debouncing: wait until the user pauses

A search box that contacts a server on every key press wastes work: typing "Pune" would send four searches. Debouncing means "wait until things go quiet, then act once". Each new key press cancels the previous timer and starts a new one, so the search runs only after the user pauses.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Debounced search</title>
</head>
<body>
  <label>Search stations <input id="search" type="search"></label>
  <p id="typed">Key presses: 0</p>
  <p id="result">Start typing...</p>

  <script>
    function debounce(fn, delay) {
      let timerId;
      return (...args) => {
        clearTimeout(timerId);                          // cancel the previous plan
        timerId = setTimeout(() => fn(...args), delay); // and make a new one
      };
    }

    const stations = ["New Delhi", "Rohtak Junction", "Jaipur Junction", "Pune Junction", "Lucknow Charbagh", "Ernakulam Junction"];
    let presses = 0;

    const search = debounce((text) => {
      const matches = stations.filter((s) => s.toLowerCase().includes(text.toLowerCase()));
      document.querySelector("#result").textContent =
        `Searched for "${text}" at ${new Date().toLocaleTimeString("en-IN")}: ${matches.join(", ") || "no match"}`;
    }, 400);

    document.querySelector("#search").addEventListener("input", (event) => {
      presses++;
      document.querySelector("#typed").textContent = `Key presses: ${presses}`;
      search(event.target.value);
    });
  </script>
</body>
</html>

Type "pune" quickly: the key-press count goes up to 4, but the search runs only once. The debounce function works because of a closure: the returned function remembers timerId between calls (see Scope, Hoisting & Closures).

requestAnimationFrame for smooth animation

For animation, timers are the wrong tool. Screens redraw at a fixed rate, often 60 times a second and sometimes 120 or more, and a timer does not line up with those redraws, so movement looks jerky.

requestAnimationFrame(callback) asks the browser to call your function just before the next redraw. The browser passes a timestamp in milliseconds. To keep animating, the callback requests another frame. The browser also pauses frames in hidden tabs, which saves battery. cancelAnimationFrame(id) stops a frame that has been requested.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Animation frame</title>
  <style>
    .pitch { position: relative; block-size: 40px; inline-size: 300px; background: #c8e6c9; border-radius: 20px; }
    .ball { position: absolute; inset-block-start: 5px; inline-size: 30px; block-size: 30px; border-radius: 50%; background: crimson; }
  </style>
</head>
<body>
  <div class="pitch"><div class="ball" id="ball"></div></div>
  <p><button type="button" id="bowl">Bowl</button> <span id="info"></span></p>

  <script>
    const ball = document.querySelector("#ball");
    const info = document.querySelector("#info");
    const distance = 270;  // pixels to travel
    const duration = 1500; // milliseconds
    let frameId = null;

    function bowl() {
      cancelAnimationFrame(frameId); // stop any delivery already in progress
      let start = null;
      let frames = 0;

      function step(timestamp) {
        start ??= timestamp;
        frames++;
        const progress = Math.min((timestamp - start) / duration, 1); // 0 to 1
        ball.style.translate = `${progress * distance}px`;

        if (progress < 1) {
          frameId = requestAnimationFrame(step); // ask for the next frame
        } else {
          info.textContent = `Done in ${frames} frames`;
        }
      }

      frameId = requestAnimationFrame(step);
    }

    document.querySelector("#bowl").addEventListener("click", bowl);
  </script>
</body>
</html>

The position is calculated from the elapsed time, not by adding a few pixels per frame. That way the ball takes 1.5 seconds on every screen. A 60 Hz screen shows about 90 frames and a 120 Hz screen about 180, but the ball arrives at the same moment.

Common mistakes

  • Calling the function instead of passing it. setTimeout(showMessage(), 1000) runs showMessage straight away. Write setTimeout(showMessage, 1000).
  • Passing a string of code. setTimeout("save()", 1000) works like eval, which is slow and unsafe. Always pass a function.
  • Forgetting to clear an interval. It keeps running forever, and every extra click may start another one.
  • Expecting exact timing. A delay is a minimum. Use Date.now() or the frame timestamp for anything that must match real time.
  • Animating with setInterval. Use requestAnimationFrame and base the movement on elapsed time.

Key points

  • setTimeout runs a function once after at least the given delay; clearTimeout cancels it.
  • setInterval repeats until you call clearInterval with its ID.
  • Timers never pause your code; the callback runs later, after the current code finishes.
  • Calculate countdowns from the clock, not by counting ticks.
  • Debouncing cancels and restarts a timer so work happens once, after the user pauses.
  • requestAnimationFrame syncs animation with screen redraws; base movement on elapsed time.

Exercise

Make a "flash sale" banner that says "Sale ends in 15s" and counts down once per second. When the time runs out, change the text to "Sale over" and stop the interval. Add a "Hide" button that hides the banner and also stops the countdown, so no timer keeps running in the background.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Flash sale</title>
</head>
<body>
  <p id="banner">Sale ends in 15s</p>
  <button type="button" id="hide">Hide</button>

  <script>
    const banner = document.querySelector("#banner");
    const endsAt = Date.now() + 15000;

    const timerId = setInterval(() => {
      const left = Math.ceil((endsAt - Date.now()) / 1000);
      if (left <= 0) {
        clearInterval(timerId);
        banner.textContent = "Sale over";
      } else {
        banner.textContent = `Sale ends in ${left}s`;
      }
    }, 1000);

    document.querySelector("#hide").addEventListener("click", () => {
      clearInterval(timerId);
      banner.hidden = true;
    });
  </script>
</body>
</html>

Both paths call clearInterval, so the interval always stops, whether the time runs out or the user hides the banner first.

Quick Quiz

1. What does this print first: setTimeout(() => console.log("A"), 0); console.log("B");?

2. How do you stop a running setInterval?

3. What does debouncing do for a search box?

4. Why is requestAnimationFrame better than setInterval for animation?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call