JavaScript setTimeout, setInterval and Debounce vs Throttle
JavaScript timer delays are minimums, not exact times. setInterval versus recursive setTimeout, nextTick versus setImmediate, and debounce versus throttle.
- Course: JavaScript study plan
- Module: Asynchronous JavaScript
- Kind: Lesson
- Reading time: 12 min
- Runtime: Node 16
What is the difference between debounce and throttle in JavaScript?
Debounce and throttle both limit how often a frequently fired function runs in JavaScript. A debounced function runs once, wait milliseconds after the last call — right for search-as-you-type, resize and autosave. A throttled function runs at most once per wait milliseconds, immediately on the first call, so it keeps making progress during a burst — right for scroll handlers and progress updates.
Lesson
Timers are the simplest asynchronous API and the most misunderstood: setTimeout(fn, 100) promises "not before 100 ms", not "at 100 ms"; setInterval drifts and overlaps; a forgotten timer keeps a Node process alive; and the three "run this soon" calls — process.nextTick, queueMicrotask, setImmediate — mean three different things. This lesson covers each primitive precisely, the promise-based timers in timers/promises, the two rate-control patterns every UI and every API client needs (debounce, throttle), and measuring time correctly.
setTimeout and clearTimeout
const id = setTimeout(() => console.log("later"), 100); // returns a handle (an object in Node, a number in browsers)
clearTimeout(id); // cancels if it has not fired; harmless otherwise
setTimeout(greet, 100, "Ada"); // extra arguments are passed to the callback
The delay is a minimum: the callback runs at the first timers phase after the delay elapses and the stack is free. A delay of 0 (or omitted) means "next timers phase"; browsers clamp nested timeouts to ≥ 4 ms and background tabs to ≥ 1 s; Node clamps below 1 to 1 and above 2³¹−1 to 1 (with a warning — a 25-day timer overflows). Same-delay timers fire in creation order. The callback's this is undefined/the global — use an arrow.
setInterval, and why a recursive timeout is often better
const id = setInterval(tick, 1000);
clearInterval(id);
setInterval schedules every n ms regardless of how long tick took — if tick is slow or the loop is busy, callbacks bunch up and drift accumulates; an async tick can overlap with itself. A recursive setTimeout — function loop() { tick(); setTimeout(loop, 1000); } or, with await, while (running) { await tick(); await sleep(1000); } — waits for the work to finish before scheduling the next run and never overlaps. Use setInterval for cheap, fixed-cadence work (a clock display); use the recursive form for anything that does I/O.
Keeping the process alive — ref/unref
In Node an active timer (or interval) keeps the event loop running: a script with a pending setTimeout does not exit until it fires. timer.unref() says "do not keep the process alive for me" — right for a periodic cache sweep or a heartbeat that should stop when the real work ends. timer.ref() reverses it. Forgetting to clear an interval is the usual reason a CLI "hangs" after printing its result.
The "soon" family
| Call | When it runs | Use |
|---|---|---|
process.nextTick(fn) | before any other queued microtask, right after the current operation | Node-specific; emit an event after the constructor returns; rarely in application code |
queueMicrotask(fn) | after nextTicks, with promise reactions, before any task | standard; "after this sync code, before anything else" |
Promise.resolve().then(fn) | same queue as queueMicrotask | idiomatic in promise code |
setImmediate(fn) | the check phase — after I/O callbacks of this iteration, before the next timers | "yield to the event loop, then continue" (Node); breaking up CPU work |
setTimeout(fn, 0) | the next timers phase | portable "yield"; slightly later than setImmediate after I/O |
Microtasks (nextTick, queueMicrotask) do not let I/O or timers run in between — a recursive microtask starves the loop. To yield for real, use setImmediate (Node) or setTimeout(fn, 0); in browsers requestAnimationFrame runs before the next paint and requestIdleCallback when idle.
Promise-based timers
const { setTimeout: sleep, setInterval: every } = require("timers/promises"); // Node 15+
await sleep(100); // resolves after 100 ms
const v = await sleep(100, "value"); // resolves with "value"
await sleep(5000, undefined, { signal }); // rejects with AbortError when the signal aborts — a cancellable sleep
for await (const tick of every(1000, Date.now())) { if (done()) break; } // an async iterator that yields every second
timers/promises makes waits composable with await, Promise.race (timeouts) and AbortSignal. Without it, the one-liner is const sleep = (ms) => new Promise((r) => setTimeout(r, ms)).
Debounce and throttle
Both limit how often a frequently-fired function actually runs, differently:
// debounce: run once, `wait` ms after the LAST call — search-as-you-type, resize, autosave
function debounce(fn, wait) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), wait); };
}
// throttle: run at most once per `wait` ms, immediately on the first call — scroll handlers, progress updates
function throttle(fn, wait) {
let last = -Infinity;
return (...args) => { const now = Date.now(); if (now - last >= wait) { last = now; fn(...args); } };
}
Debounce waits for quiet: a burst of 20 keystrokes produces one call after the typing stops (the last arguments win). Throttle guarantees progress during the burst: one call per window (the first arguments in each window). Variants add a *leading*/*trailing* option, a cancel() method (clear the timer on unmount — a debounced call firing after a component is gone is a classic React bug) and a flush(). Lodash's debounce/throttle are the reference implementations.
Measuring time
Date.now() is wall-clock milliseconds — fine for timestamps, wrong for durations (it jumps when the clock is adjusted). performance.now() (global in Node 16+ and browsers) is a monotonic high-resolution timer for measuring: const t0 = performance.now(); …; const ms = performance.now() - t0. process.hrtime.bigint() is the nanosecond version. console.time(label)/console.timeEnd(label) is the quick-and-dirty form.
Common mistakes
- Relying on exact timer timing or on
setTimeout(0)ordering against I/O. setIntervalfor work that does I/O — overlapping runs and drift.- A pending timer or interval keeping a script alive (or
unrefon a timer that should keep it alive). - Recursive
nextTick/queueMicrotaskstarving the loop. - Debounce without
cancelon teardown; throttle where debounce was wanted (or vice versa). - Measuring durations with
Date.now().
Interview angle
- "What does
setTimeout(fn, 0)guarantee?" Only thatfnruns no sooner than the next timers phase, after the current code and all microtasks. - "Debounce versus throttle?" Debounce fires once after calls stop; throttle fires at most once per interval during the calls.
- "
setIntervalversus recursivesetTimeout?" Fixed cadence regardless of duration (drift, overlap) versus scheduling the next run after the work completes. - "
process.nextTickversussetImmediate?" nextTick runs before any I/O or timers (microtask-like, first); setImmediate runs in the check phase after I/O. - "How do you measure elapsed time?"
performance.now()— monotonic;Date.now()is wall-clock and can jump.
Key takeaways
- Timers are minimums; same-delay order is creation order;
clearTimeout/clearIntervalcancel; Node timers keep the process alive unlessunref(). - Prefer recursive
setTimeout/await sleeploops tosetIntervalfor real work. nextTick→ microtasks →setImmediate/timers; only the last two truly yield to I/O.timers/promisesgives awaitable, abortable sleeps and intervals.- Debounce (after the burst) versus throttle (during the burst), both with
cancel; measure withperformance.now().
Common questions
Does setTimeout(fn, 0) run immediately?
No. The delay is a minimum: fn runs no sooner than the next timers phase, after the current synchronous code and every pending microtask. Under load it runs later still, and browsers clamp nested timeouts to at least 4 ms.
Should I use setInterval or a recursive setTimeout?
setInterval fires every n ms regardless of how long the work took, so slow or async callbacks drift and can overlap. A recursive setTimeout, or an await sleep() loop, schedules the next run only after the work finishes. Use setInterval for cheap fixed-cadence work and the recursive form for anything doing I/O.
What is the difference between process.nextTick and setImmediate?
process.nextTick runs its callback before any other queued microtask, right after the current operation and before any I/O or timers. setImmediate runs in the check phase, after the current iteration's I/O callbacks. Only setImmediate genuinely yields to the event loop; recursive nextTick calls starve it.
Why does my Node.js script not exit?
A common cause is an active timer: a pending setTimeout or an uncleared setInterval keeps Node's event loop running. Clear it with clearTimeout or clearInterval when the work is done, or call timer.unref() on a background timer that should not keep the process alive.
How do you measure elapsed time in JavaScript?
Use performance.now(), a monotonic high-resolution timer available globally in browsers and in Node 16+, and subtract a start reading from an end reading. Date.now() is wall-clock time and can jump when the system clock is adjusted, so it suits timestamps, not durations.
Exercises
Debounce and throttle, side by side
Write debounce(fn, wait) (run fn once, wait ms after the last call, with the last arguments) and a leading-edge throttle(fn, wait) (run immediately, then at most once per wait ms, using Date.now()). Line 1 is wait; each following line t label schedules a call to both wrappers at t ms. The wrapped functions print debounce:<label> / throttle:<label>; after the last call plus 2 × wait print calls=<n> debounceFired=<n> throttleFired=<n>.
Example: 40, then calls 0 A, 10 B, 20 C, 100 D, 110 E →
throttle:A
debounce:C
throttle:D
debounce:E
calls=5 debounceFired=2 throttleFired=2
Throttle fires at the start of each burst; debounce fires once each burst is over.
Intervals, promise timers and an aborted sleep
Write ticks(n, everyMs) returning a promise: a setInterval every everyMs prints tick <i> and, after n ticks, clears itself and resolves with the count. Then, using require("timers/promises"): await timers.setTimeout(5, "value") and print promise timer resolved with <value>; create an AbortController, abort it after 5 ms, and await timers.setTimeout(1000, undefined, { signal }) inside try/catch, printing long sleep aborted: <err.name> aborted=<signal.aborted>; finally create an interval, call .unref() on it and print unref: process may exit — the program must end on its own.
Example: 3 →
tick 1
tick 2
tick 3
interval done after 3 ticks
promise timer resolved with value
long sleep aborted: AbortError aborted=true
unref: process may exitIn this module: Asynchronous JavaScript
- Callbacks and the event loop — one thread, two queues
- Promises — a value that is not here yet
- async/await — promises with the syntax of ordinary code
- Combinators, concurrency limits, timeouts and cancellation
- Timers and scheduling — setTimeout, setInterval, nextTick, and debounce/throttle (this lesson)
- Checkpoint — Asynchronous JavaScript
← Combinators, concurrency limits, timeouts and cancellation · Checkpoint — Asynchronous JavaScript →