Episode 1 — Fundamentals / 1.17 — Asynchronous Programming JavaScript
1.17.e — Timers: setTimeout, setInterval, and Timing Precision
In one sentence:
setTimeoutschedules a one-time delayed callback,setIntervalschedules 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, ...);
| Parameter | Description |
|---|---|
callback | Function to execute after the delay |
delay | Time 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.
| Factor | Effect on timer |
|---|---|
| Call stack busy | Callback waits until stack is empty |
| Nested timers (depth > 4) | Minimum 4ms delay enforced |
| Background tab | Intervals throttled to ~1000ms |
| System load | OS 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
| Aspect | setInterval | Recursive setTimeout |
|---|---|---|
| Gap between callbacks | From start of previous call | From end of previous call |
| Overlapping risk | Yes, if callback > interval | No |
| Consistent gap | No — shrinks as callback gets slower | Yes — always exactly delay ms gap |
| Stopping | clearInterval(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
| Scenario | Problem | Fix |
|---|---|---|
| SPA route change | Interval from old page still runs | Clear on route/component cleanup |
| Modal/dialog closed | Interval from modal keeps firing | Clear in close handler |
| Event listener + interval | Both accumulate on re-attach | Remove listener + clear interval |
| Development hot reload | Old intervals survive module reload | Guard with global reference |
10. Timer-related APIs worth knowing
| API | Purpose |
|---|---|
setTimeout | One-time delayed execution |
setInterval | Repeated execution |
clearTimeout / clearInterval | Cancel pending timer |
requestAnimationFrame | Schedule work before next repaint (~16ms, synced to display refresh) |
queueMicrotask | Schedule a microtask (higher priority than setTimeout) |
requestIdleCallback | Schedule low-priority work when browser is idle |
Key takeaways
setTimeout= one-time delay;setInterval= repeating. Both return IDs for cancellation.- Timer delays are minimums, not guarantees — the event loop, nesting rules, and tab throttling add variability.
setTimeout(fn, 0)means "next macrotask" — after current script and all microtasks.- Recursive
setTimeoutis more reliable thansetIntervalfor consistent gaps between executions. - Always clear intervals when they are no longer needed to prevent memory leaks.
- Debounce (wait until activity stops) and polling (check periodically) are the two most common timer patterns.
Explain-It Challenge
Explain without notes:
- Why does
setTimeout(fn, 0)not execute the callback immediately? - What is the drift problem with
setInterval, and how does recursivesetTimeoutsolve it? - How can a forgotten
setIntervalcause a memory leak?
Navigation: ← 1.17.d — Async/Await · 1.17 Exercise Questions →