Articles

setTimeout vs setInterval in JavaScript

setTimeout runs a callback once after a delay; setInterval repeats it on a fixed cadence — but drifts under load in ways setTimeout recursion avoids.

Takina Takina · · 4 min read
Code editor showing JavaScript source

setTimeout schedules a callback to run once after a minimum delay, while setInterval schedules a callback to run repeatedly on a fixed cadence until it’s explicitly cleared. Both are asynchronous, both are queued through the same event loop mechanism, and both take a delay in milliseconds — but they behave very differently once the work inside the callback takes longer than the interval itself, which is where most of the confusing bugs come from.

The basic signatures

const timeoutId = setTimeout(() => {
  console.log("runs once, after at least 1000ms");
}, 1000);

const intervalId = setInterval(() => {
  console.log("runs every 1000ms, roughly");
}, 1000);

clearTimeout(timeoutId);
clearInterval(intervalId);

Both return an identifier you pass to their corresponding clear* function to cancel the scheduled work. Neither delay is a guarantee — it’s a minimum. The callback is queued as a task and only runs once the call stack is empty and the event loop gets to it, so a busy main thread can push either one back arbitrarily.

Why setInterval drifts

setInterval’s repeat cadence is based on wall-clock time from when it was first scheduled, not on when the previous callback finished. If a callback occasionally takes longer than the interval — say a 100ms interval with a callback that takes 150ms under load — the browser doesn’t wait for it to finish before considering the next tick due. Depending on the environment, ticks can queue up and then fire back-to-back once the thread frees up, or get skipped entirely. Either way, the “every N milliseconds” guarantee erodes under real workloads.

The standard fix is to replace setInterval with a self-scheduling setTimeout:

function poll() {
  doWork();
  setTimeout(poll, 1000);
}
setTimeout(poll, 1000);

Here, the next call is only scheduled after the current one completes, so the gap between calls never drops below 1000ms — it can only stretch longer if doWork runs long, never compound into a burst of queued callbacks. This pattern is common enough in polling loops that it’s worth defaulting to it over raw setInterval any time the callback’s duration isn’t trivially fast and constant.

Comparing the two

setTimeoutsetInterval
FiresOnceRepeatedly, until cleared
Cancel withclearTimeout(id)clearInterval(id)
Cadence guaranteeN/A — single delayed callApproximate; can drift or queue under load
Self-correcting patternN/ARecursive setTimeout avoids overlap
Typical useDebounce, delayed one-off actions, retry backoffFixed polling when callback duration is short and predictable

Where each one belongs

setTimeout is the right primitive for anything that should happen once after a delay: debouncing user input (see debounce vs throttle for the distinction between the two rate-limiting patterns), delaying a UI transition, or implementing exponential backoff between retry attempts, where each delay is intentionally longer than the last and a fixed-interval timer wouldn’t fit the pattern at all.

setInterval still has a place for lightweight, fast, and predictable repeating work — a UI clock tick, a periodic “still connected” check where slight drift doesn’t matter. But for anything involving network calls, DOM measurement, or variable-duration work, the recursive setTimeout pattern is safer because it guarantees no overlap between calls.

Neither one is precise

A common misconception is that these APIs offer real-time precision. They don’t. Browsers throttle timers in backgrounded tabs — often to once per second or slower — specifically to save battery and CPU, and the HTML spec itself defines a minimum clamp (historically 4ms for nested timeouts) that prevents runaway timer chains from starving the main thread. If you need frame-accurate timing for animation, requestAnimationFrame is the correct API — it’s synced to the browser’s paint cycle rather than an arbitrary millisecond count, and it automatically pauses when the tab isn’t visible.

Cleanup matters

Both timers keep a reference alive until cleared, which is a common source of memory leaks and stray callbacks firing after a component or module they belonged to has been torn down. In frameworks with component lifecycles, always pair the setTimeout/setInterval call with a corresponding clearTimeout/clearInterval in the cleanup path — a setInterval that outlives the UI it was updating will keep firing (and keep holding onto whatever it closed over via a closure) until the page unloads or the interval is explicitly cleared.

If you’re coordinating multiple async operations that use timers internally — a timeout racing against a fetch, for instance — Promise.race combined with a setTimeout-based timeout promise is the standard pattern for implementing a request timeout in plain JavaScript.

Passing arguments and clearing safely

Both functions accept extra arguments after the delay, which get forwarded to the callback when it fires — setTimeout(fn, 1000, arg1, arg2) calls fn(arg1, arg2) once the delay elapses, avoiding the need for a wrapper arrow function purely to pass data through. And clearing a timer that already fired, or one whose ID is undefined, is always safe: clearTimeout and clearInterval silently no-op on an invalid or already-consumed ID rather than throwing, so defensive if (id) clearTimeout(id) checks are unnecessary overhead, not a required safety measure.

The takeaway

setTimeout fires once after a minimum delay; setInterval repeats on a cadence that can drift or overlap once the callback’s duration approaches the interval itself. For anything beyond simple, fast, fixed-cadence work, a self-scheduling setTimeout loop is the more predictable choice because it only queues the next call after the current one finishes. Whichever you use, clear it explicitly when the work it supports goes away — an uncleared timer is one of the easiest leaks to introduce and one of the easiest to avoid.

Takina Takina · · 6 min read

How to Break Up Long Tasks in JavaScript

Long tasks block the main thread for 50ms or more and make pages feel frozen. How to split them with yielding, scheduler.yield(), postTask, and workers.

#JavaScript #Performance #Web Development
Takina Takina · · 5 min read

Microtasks vs Macrotasks in JavaScript, Explained

Microtasks (promise callbacks) run before the next macrotask (timers, events). How the two queues are ordered, and why it matters for your code.

#JavaScript #Web Development #Performance
Takina Takina · · 5 min read

OffscreenCanvas API Explained

OffscreenCanvas lets you render canvas graphics off the main thread, in a web worker, so heavy drawing work stops blocking scrolling, input, and animation.

#JavaScript #Web Development #Performance