skip to content

Macrotask Queue and Timers

Timer callbacks, I/O completions, and dispatched events land in the task queue, and exactly one task is picked up per turn of the loop. Interviewers focus on timers here because almost everyone assumes setTimeout is a precise scheduler, and it never is.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

13

What does setInterval return, and what happens if the code that started a repeating timer is torn down without ever calling clearInterval with that value?

level: juniorimportance: must knowfreq 62%

answer

  1. the handle exists only to cancel
  2. nothing stops it on its own
  3. closure stays reachable while active
  4. each call creates another timer
  5. clear before re-arming

basics

~20 s

setInterval returns a timer handle — a number in browsers, a Timeout object in Node — whose only use is to pass to clearInterval. Without that call the timer fires forever, keeping its callback and everything the callback closes over alive.

solid answer

~50 s

`setInterval` hands back an opaque handle identifying the timer inside the host's list of active timers: a positive integer in browsers, a `Timeout` object in Node. It carries no other information; its whole purpose is `clearInterval(handle)`. Nothing else stops a repeating timer — not the callback returning a value, not an exception thrown inside it, not the variable holding the handle going out of scope. That is why an uncleared interval is a real leak rather than just wasted CPU: the host keeps a strong reference to the callback, and the callback is usually a closure over the surrounding scope, so a whole object graph stays reachable. Meanwhile the callback keeps running against a world that no longer exists — writing to detached nodes, refetching for a screen nobody is looking at. Store the handle, clear it in teardown, and clear before re-arming so you never stack two timers.

code

javascript · 14 lines
javascript
let timerId = null;

function start(intervalMs) {
  stop(); // never stack a second timer on top of the first
  timerId = setInterval(() => console.log('poll', Date.now()), intervalMs);
}

function stop() {
  clearInterval(timerId); // a stale or null handle is a silent no-op
  timerId = null;
}

start(1000);
setTimeout(stop, 5000);

go deeper

for a junior

Know that setInterval returns a handle whose only purpose is clearInterval, and be able to write the start/stop pair that stores the handle and clears it on teardown.

for a middle

Explain why an uncleared interval pins the callback's whole closure scope in memory, and why calling the setup path twice produces two independent timers rather than reusing one.

for a senior

Show how you would diagnose it in production: sawtooth memory that never returns to baseline, requests or errors repeating at a round interval, and a heap snapshot where a timer callback is the retaining root.

for a principal

Argue for eliminating the class rather than fixing instances — ownership rules for timer handles, teardown enforced by the framework or a lifecycle helper, and a policy on whether repeating work should stop or back off when it starts failing.

## What the return value actually is `setInterval(callback, delay)` registers a repeating timer with the host environment and returns a handle. In a browser that handle is a positive integer, unique within that global (a `window` or a worker); in Node it is a `Timeout` object that also exposes `ref()`, `unref()` and `refresh()`. Either way, treat it as opaque: it is a key into the host's map of active timers, not something to compare, serialize, or do arithmetic on. ```js const id = setInterval(() => console.log('tick'), 1000); // ...later clearInterval(id); ``` ## Cancellation is the only exit A repeating timer has exactly one stopping condition: someone calls `clearInterval` with its handle. It is worth listing what does *not* stop it, because each is a real candidate answer from a weak interviewee: - **Returning a value from the callback** does nothing. There is no "return false to unsubscribe" convention here. - **Throwing inside the callback** ends only that one invocation. The exception surfaces as an uncaught error (`window.onerror` / `unhandledrejection` for async ones, `uncaughtException` in Node), and the next tick fires on schedule. A bug that throws throws once per period, forever. - **Losing the handle** — reassigning the variable, letting the enclosing function return — does nothing, because the host holds the timer, not your variable. Losing the handle just means you can no longer cancel it. `clearInterval` with an unknown, already-cleared, `null` or `undefined` handle is a silent no-op, never an error, so defensive clearing is safe and cheap. In browsers `clearTimeout` and `clearInterval` operate on the same map of active timers, so either one will clear either kind of timer — true, but relying on it reads as a mistake, so use the matching pair. ## Why an uncleared interval is a leak, not just a waste While a timer is active the host holds a strong reference to its callback function. Callbacks are almost always closures, so that reference transitively pins everything in the closed-over scope: the element you captured, the response payload you cached, the object holding the screen's state. Garbage collection cannot help — the object graph is genuinely reachable from a live root. The visible symptoms are the interesting part in an interview: memory that climbs in a sawtooth that never returns to baseline as the user navigates around; network requests for a view that closed minutes ago; errors like "cannot set property of null" repeating at a suspiciously round interval; and in a long-lived tab, a slow slide in responsiveness as more and more zombie timers share the main thread. ## Stacking: the other half of the bug Every call to `setInterval` creates an **independent** timer. Nothing deduplicates them. Setup code that runs again without a matching teardown therefore doubles the tick rate, and doubles again on the next run: ```js let timerId = null; function start(ms) { stop(); // clear before re-arming — never stack timerId = setInterval(poll, ms); } function stop() { clearInterval(timerId); // stale or null handle: silent no-op timerId = null; } ``` The giveaway in a bug report is "it polls twice per second, and after I open and close the panel a few times it polls constantly". Keeping the handle in exactly one place and clearing before re-arming removes the whole class. ## Host-specific consequence: process lifetime in Node In Node an active timer is a referenced handle that keeps the event loop alive, so a script with an uncleared interval never exits on its own. `handle.unref()` opts a specific timer out of that, letting the process exit while the timer is still pending. In a browser the equivalent consequence is simply that the timer lives as long as the document does. ## Practical checklist - Capture the handle in a single owning place; do not scatter copies. - Clear it in whatever teardown hook the surrounding code has, and clear before re-arming. - If the callback can throw, wrap its body in `try/catch` and decide deliberately whether a failure should cancel the timer — otherwise it repeats the failure indefinitely. - If the repeating work should stop on error, back off, or vary its delay, a self-rescheduling `setTimeout` gives you those decisions at the point where you choose the next delay; `setInterval` gives you only "on" and "off".

  • Does an exception thrown inside a setInterval callback stop the timer?
    No. Each invocation is a separate task, so the exception ends that invocation and surfaces as an uncaught error, while the timer stays registered and fires again next period. A callback that throws because the page it targets is gone will keep throwing forever. If a failure should end the schedule, catch it inside the callback and call `clearInterval` yourself.
  • What happens if you call clearInterval with a handle that was already cleared, or with undefined?
    Nothing — it is a silent no-op, not an error, because the handle simply is not in the host's map of active timers. That makes a defensive `clearInterval(timerId)` in a teardown path safe even when no timer is running, which is why the clear-then-re-arm pattern is cheap to apply everywhere.
  • In Node, why does a script with an active setInterval never exit?
    An active timer is a referenced handle, and Node keeps the event loop running while any referenced handle is pending. Calling `unref()` on the `Timeout` object returned by `setInterval` marks it as not keeping the process alive, so the script can exit with the timer still scheduled. Browsers have no equivalent — the timer simply lives as long as the document.

saying these in an interview costs you the question

  • Thinks the timer stops when the callback throws
  • Believes GC reclaims a timer once its handle is unreachable
  • Says returning false from the callback cancels it
  • Calls setInterval again on re-entry without clearing the old handle
  • Passes the callback function, not the handle, to clearInterval

context

open as a page

Does setTimeout(fn, 100) guarantee that fn runs exactly 100 ms later? What does the delay argument actually promise?

level: juniorimportance: must knowfreq 78%

basics

~20 s

setTimeout's delay is a minimum wait, not an exact schedule. When it elapses the callback is only queued as a task; it runs after the currently executing code finishes and after any work already queued ahead of it, so it commonly fires late.

open as a page

In browser or Node JavaScript, what does setTimeout(fn, 0) actually do — when does fn run relative to the code written after the setTimeout call?

level: juniorimportance: must knowfreq 70%

basics

~20 s

setTimeout(fn, 0) schedules fn as a separate later task instead of running it now. Every remaining statement in the current script runs first, then all pending promise callbacks, and only then does fn execute — typically a few milliseconds later.

open as a page

In JavaScript, what is the difference between repeating work with setInterval(fn, 1000) and having fn schedule itself with setTimeout(fn, 1000) as its last step, and when does that difference matter?

level: middleimportance: must knowfreq 70%

basics

~20 s

setInterval measures its period from tick to tick, so a slow callback eats into the gap and can run back-to-back. A self-rescheduling setTimeout guarantees a full delay after each run finishes, at the cost of a period that grows by the callback's own duration.

open as a page

A status poller runs setInterval(() => refresh(), 1000), where refresh() is asynchronous and usually takes about three seconds. What goes wrong, and what would you use instead?

level: middleimportance: should knowfreq 48%

basics

~20 s

The timer ignores whatever refresh() returns, so it starts a new one every second regardless: about three run concurrently, and the count grows if refresh slows down. Replace the interval with a self-rescheduling setTimeout armed only after each run settles.

open as a page

In a browser, a function reschedules itself with setTimeout(step, 0) over and over. Why do the steps settle to roughly one every 4 ms instead of running back-to-back?

level: middleimportance: should knowfreq 45%

basics

~20 s

Browsers apply the HTML standard's nesting clamp: once a timer chain runs more than five levels deep, a requested delay below 4 ms is raised to 4 ms. The engine is not lagging — the host is enforcing a floor.

open as a page

Inside one task you call setTimeout(a, 100), then setTimeout(b, 100), then setTimeout(c, 50). In what order do the three callbacks run, and what rule decides it?

level: middleimportance: should knowfreq 35%

basics

~20 s

Order is decided by expiry time, with ties broken by registration order: c runs first because it expires 50 ms earlier, then a, then b. Registration order only matters between timers that come due at the same moment.

open as a page

Inside a long-running loop, does adding `await Promise.resolve()` on every iteration give the event loop a chance to run other queued work, the way `await new Promise(r => setTimeout(r, 0))` does?

level: middleimportance: should knowfreq 42%

basics

~20 s

No. Awaiting an already-resolved promise only queues a microtask, and microtasks are drained inside the current task before the runtime moves on, so the thread is never released. Only crossing a task boundary, such as awaiting a zero-delay timer, actually yields.

open as a page

You have to process a 200,000-item array in the browser and doing it in a single loop freezes the page. How do you use setTimeout to split that work so the page stays responsive, and what does the resulting code look like?

level: middleimportance: should knowfreq 50%

basics

~20 s

Keep a cursor into the array and process only as many items as fit in a small time budget, roughly 5 ms. When the budget is spent, call setTimeout with the continuation and a zero delay so the next batch runs as a fresh task, letting queued work run in between.

open as a page

A countdown decrements a seconds counter inside setInterval(tick, 1000) and renders it. On a busy page it is noticeably behind real time after an hour. Why does that happen, and how would you make it accurate?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Counting ticks measures how many times the timer fired, not how much time passed. Each tick can only be late, never early, and the lateness accumulates. Compute remaining time from a timestamp captured at the start rather than from a tick count.

open as a page

A browser page schedules work with setTimeout while the user switches to another tab for ten minutes. What does the browser do to those timers, and how do you write code that survives it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Hidden tabs get their timers throttled: typically no more than about once per second, and far less after several minutes in the background. Treat setTimeout as a wake-up hint and derive elapsed time from timestamps, never from how often a callback ran.

open as a page

What happens when you call setTimeout(fn, 2 ** 31) — a delay of about 25 days — and why?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

The delay is held in a 32-bit signed integer, so anything above 2147483647 ms (about 24.8 days) overflows. Browsers then treat it as zero and fire almost immediately; Node clamps it to 1 ms and prints a TimeoutOverflowWarning.

open as a page

A colleague made a long job responsive by calling setTimeout(next, 0) after every single item. Each item takes microseconds, yet the job now processes only a few hundred items per second. What is going on, and how would you fix it?

level: seniorimportance: nice to knowfreq 33%

basics

~20 s

A zero delay is not zero. Browsers clamp deeply nested zero-delay timers to about 4 ms and Node raises a delay below 1 ms to 1 ms, so a per-item timer chain is capped at a few hundred to a thousand hops per second. Batch by a time budget and yield once per batch instead.

open as a page