Episode 1 — Fundamentals / 1.17 — Asynchronous Programming JavaScript

1.17.e — Timers: setTimeout, setInterval, and Timing Precision

In one sentence: setTimeout schedules a one-time delayed callback, setInterval schedules a repeating callback, but both are macrotasks subject to event loop delays — understanding their quirks (minimum delay, drift, memory leaks) is essential for reliable timed behavior.

Navigation: ← 1.17.d — Async/Await · 1.17 Exercise Questions →


1. setTimeout(callback, delay) — one-time delayed execution

setTimeout registers a callback to run once after at least delay milliseconds.

console.log("Before");

setTimeout(function () {
  console.log("Delayed by 2 seconds");
}, 2000);

console.log("After");

// Output:
// Before
// After
// Delayed by 2 seconds  (after ~2s)

Syntax

const timerId = setTimeout(callback, delay, arg1, arg2, ...);
ParameterDescription
callbackFunction to execute after the delay
delayTime in milliseconds (default 0 if omitted)
arg1, arg2, ...Optional arguments passed to the callback
function greet(name, punctuation) {
  console.log("Hello, " + name + punctuation);
}

setTimeout(greet, 1000, "Alice", "!"); // "Hello, Alice!" after 1 second

Return value

setTimeout returns a timer ID (a positive integer in browsers, an object in Node.js) that can be used to cancel the timer.


2. clearTimeout(id) — cancelling a timeout

const timerId = setTimeout(function () {
  console.log("This will NOT run");
}, 5000);

// Cancel before it fires
clearTimeout(timerId);
console.log("Timer cancelled");

Practical example: cancellable operation

let searchTimer;

function onSearchInput(query) {
  clearTimeout(searchTimer);               // cancel previous timer
  searchTimer = setTimeout(function () {
    performSearch(query);                   // only runs after user stops typing
  }, 300);
}

This is the foundation of the debounce pattern (see section 8).


3. setInterval(callback, interval) — repeated execution

setInterval calls the callback repeatedly, with (approximately) interval milliseconds between each call.

let count = 0;
const intervalId = setInterval(function () {
  count++;
  console.log("Tick #" + count);
  if (count >= 5) {
    clearInterval(intervalId); // stop after 5 ticks
  }
}, 1000);

// Output:
// Tick #1  (at ~1s)
// Tick #2  (at ~2s)
// Tick #3  (at ~3s)
// Tick #4  (at ~4s)
// Tick #5  (at ~5s) — then stops

Syntax

const intervalId = setInterval(callback, interval, arg1, arg2, ...);

4. clearInterval(id) — stopping intervals

const id = setInterval(function () {
  console.log("Running...");
}, 1000);

// Stop after 5 seconds
setTimeout(function () {
  clearInterval(id);
  console.log("Interval stopped");
}, 5000);

Important: Always store the interval ID and clear it when done. Forgotten intervals are a common source of memory leaks (see section 9).


5. Why timers are not exact

Timer delays are minimum delays, not exact. Several factors cause inaccuracy:

5.1 Event loop blocking

If the call stack is busy, the timer callback waits in the macrotask queue:

setTimeout(function () {
  console.log("Should fire at ~100ms");
}, 100);

// Simulate a blocking task that takes 500ms
const start = Date.now();
while (Date.now() - start < 500) {
  // blocking the main thread
}
console.log("Blocking done at", Date.now() - start, "ms");

// Output:
// Blocking done at ~500ms
// Should fire at ~100ms  ← actually fires at ~500ms!

5.2 Minimum delay (HTML spec)

The HTML specification mandates a minimum delay of 4ms for nested setTimeout calls (depth > 4). Browsers enforce this to prevent CPU abuse.

// Even setTimeout(fn, 0) is not truly instant — minimum ~4ms for nested calls
let start = Date.now();
setTimeout(function a() {
  setTimeout(function b() {
    setTimeout(function c() {
      setTimeout(function d() {
        setTimeout(function e() {
          console.log("Elapsed:", Date.now() - start, "ms"); // ~16-20ms, not 0
        }, 0);
      }, 0);
    }, 0);
  }, 0);
}, 0);

5.3 Background tab throttling

Browsers throttle timers in inactive tabs. setInterval in a background tab may fire only once per second (or even less) to save CPU and battery.

FactorEffect on timer
Call stack busyCallback waits until stack is empty
Nested timers (depth > 4)Minimum 4ms delay enforced
Background tabIntervals throttled to ~1000ms
System loadOS scheduling adds unpredictable delay

6. setTimeout(fn, 0) — what it really does

setTimeout(fn, 0) does not mean "run immediately." It means: "run this callback in the next macrotask, after the current script and all microtasks finish."

console.log("A");

setTimeout(function () {
  console.log("B"); // macrotask — runs last
}, 0);

Promise.resolve().then(function () {
  console.log("C"); // microtask — runs before macrotask
});

console.log("D");

// Output: A, D, C, B

Use cases for setTimeout(fn, 0):

  • Yield to the browser for rendering between heavy computations
  • Ensure a callback runs after the current execution context finishes
  • Break up long-running synchronous work
// Breaking up heavy work so the UI can repaint
function processLargeArray(arr) {
  let index = 0;
  function chunk() {
    const end = Math.min(index + 1000, arr.length);
    for (; index < end; index++) {
      heavyComputation(arr[index]);
    }
    if (index < arr.length) {
      setTimeout(chunk, 0); // yield to browser, then continue
    }
  }
  chunk();
}

7. Recursive setTimeout vs setInterval (drift problem)

setInterval drift

With setInterval, the interval starts counting from the beginning of the callback. If the callback takes time, intervals can overlap or drift:

setInterval at 1000ms:
|--callback(50ms)--|--------950ms--------|--callback(50ms)--|
                                          ^ fires at 1000ms from START of previous

But if callback takes 300ms:
|----callback(300ms)----|-----700ms------|----callback(300ms)----|
                                          ^ fires at 1000ms from START
                                            (only 700ms gap between callbacks)

If callback takes MORE than interval:
|----------callback(1200ms)----------|callback starts immediately|
                                      ^ no gap at all, queue backs up

Recursive setTimeout — guaranteed gap

Using recursive setTimeout, the delay starts after the callback finishes:

function reliableInterval(callback, delay) {
  function tick() {
    callback();
    setTimeout(tick, delay); // schedule next AFTER this one finishes
  }
  setTimeout(tick, delay);
}

reliableInterval(function () {
  console.log("Tick at", new Date().toLocaleTimeString());
  // Even if this takes 200ms, the next tick is 1000ms AFTER completion
}, 1000);
Recursive setTimeout at 1000ms:
|--callback(300ms)--|---1000ms---|--callback(300ms)--|---1000ms---|
                                  ^ delay starts AFTER callback ends

Comparison

AspectsetIntervalRecursive setTimeout
Gap between callbacksFrom start of previous callFrom end of previous call
Overlapping riskYes, if callback > intervalNo
Consistent gapNo — shrinks as callback gets slowerYes — always exactly delay ms gap
StoppingclearInterval(id)Do not call setTimeout again

8. Practical examples

8.1 Debounce concept

Fire a function only after the user stops doing something for a specified time:

function debounce(fn, delay) {
  let timer;
  return function () {
    const context = this;
    const args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(context, args);
    }, delay);
  };
}

// Usage: only search after user stops typing for 300ms
const searchInput = document.getElementById("search");
searchInput.addEventListener("input", debounce(function (e) {
  console.log("Searching for:", e.target.value);
}, 300));

8.2 Polling (checking for updates)

async function pollForUpdates(url, intervalMs) {
  async function check() {
    try {
      const response = await fetch(url);
      const data = await response.json();
      if (data.hasUpdate) {
        console.log("Update found:", data);
        return; // stop polling
      }
    } catch (err) {
      console.error("Poll error:", err.message);
    }
    setTimeout(check, intervalMs); // poll again after delay
  }
  check();
}

pollForUpdates("/api/status", 5000); // check every 5 seconds

8.3 Countdown timer

function startCountdown(seconds, displayElement) {
  let remaining = seconds;

  function tick() {
    displayElement.textContent = remaining + "s remaining";
    if (remaining <= 0) {
      displayElement.textContent = "Time is up!";
      return;
    }
    remaining--;
    setTimeout(tick, 1000); // recursive setTimeout for accuracy
  }

  tick();
}

startCountdown(10, document.getElementById("timer"));

8.4 Auto-save draft

let saveTimer;

function onContentChange() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(function () {
    saveDraft(); // save 2 seconds after last keystroke
    console.log("Draft saved");
  }, 2000);
}

document.getElementById("editor").addEventListener("input", onContentChange);

9. Memory leaks from forgotten intervals

setInterval callbacks hold references to their closure variables. If you never call clearInterval, the callback runs forever and those references are never garbage-collected.

// MEMORY LEAK: interval never cleared
function startBadInterval() {
  const hugeData = new Array(1000000).fill("x"); // large allocation

  setInterval(function () {
    console.log("Still running, holding", hugeData.length, "items");
  }, 1000);
  // hugeData is never released because the interval callback references it
}

How to prevent leaks

// Pattern 1: Store the ID and clear it in cleanup
const intervalId = setInterval(tick, 1000);

// Cleanup when component unmounts / page navigates
function cleanup() {
  clearInterval(intervalId);
}

// Pattern 2: Self-clearing interval
let count = 0;
const id = setInterval(function () {
  count++;
  console.log("Tick", count);
  if (count >= 10) {
    clearInterval(id); // stop itself
  }
}, 1000);

// Pattern 3: AbortController (modern approach for fetch-related polling)
const controller = new AbortController();

async function poll() {
  while (!controller.signal.aborted) {
    await fetch("/api/status", { signal: controller.signal });
    await new Promise(function (r) { setTimeout(r, 5000); });
  }
}

// To stop:
controller.abort();

Common leak scenarios

ScenarioProblemFix
SPA route changeInterval from old page still runsClear on route/component cleanup
Modal/dialog closedInterval from modal keeps firingClear in close handler
Event listener + intervalBoth accumulate on re-attachRemove listener + clear interval
Development hot reloadOld intervals survive module reloadGuard with global reference

10. Timer-related APIs worth knowing

APIPurpose
setTimeoutOne-time delayed execution
setIntervalRepeated execution
clearTimeout / clearIntervalCancel pending timer
requestAnimationFrameSchedule work before next repaint (~16ms, synced to display refresh)
queueMicrotaskSchedule a microtask (higher priority than setTimeout)
requestIdleCallbackSchedule low-priority work when browser is idle

Key takeaways

  1. setTimeout = one-time delay; setInterval = repeating. Both return IDs for cancellation.
  2. Timer delays are minimums, not guarantees — the event loop, nesting rules, and tab throttling add variability.
  3. setTimeout(fn, 0) means "next macrotask" — after current script and all microtasks.
  4. Recursive setTimeout is more reliable than setInterval for consistent gaps between executions.
  5. Always clear intervals when they are no longer needed to prevent memory leaks.
  6. Debounce (wait until activity stops) and polling (check periodically) are the two most common timer patterns.

Explain-It Challenge

Explain without notes:

  1. Why does setTimeout(fn, 0) not execute the callback immediately?
  2. What is the drift problem with setInterval, and how does recursive setTimeout solve it?
  3. How can a forgotten setInterval cause a memory leak?

Navigation: ← 1.17.d — Async/Await · 1.17 Exercise Questions →