skip to content

setTimeout(0) and Yielding to the Loop

The setTimeout(0) idiom defers work to a later task so the loop can process whatever is already queued in between. Interviewers use it to see whether you can chop a long job into chunks that release the thread instead of hogging it.

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

questions

4

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%

answer

  1. queued, not called
  2. current script finishes first
  3. promise callbacks go before it
  4. zero is a minimum, not immediate

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.

solid answer

~40 s

`setTimeout(fn, 0)` never calls `fn` synchronously. It registers a timer and returns an id immediately, so the rest of the current function and the rest of the current task keep running to completion. When the call stack empties, the runtime first drains the microtask queue — pending `.then()` callbacks, `await` continuations, `queueMicrotask` callbacks — and only after that does it pick up the timer callback as a new task. The `0` is a requested minimum, not a promise of immediacy: the host clamps very small delays, and if the thread is busy the callback simply waits. That is exactly why the idiom is used as a yield point — it is the cheapest way to say "finish what you are doing, let anything already queued run, then continue here".

code

javascript · 17 lines
javascript
console.log('sync start');

setTimeout(() => console.log('timer callback'), 0);

Promise.resolve().then(() => console.log('microtask'));

for (let i = 0; i < 3; i++) console.log('still in the same task', i);

console.log('sync end');

// sync start
// still in the same task 0
// still in the same task 1
// still in the same task 2
// sync end
// microtask
// timer callback

go deeper

for a junior

Be able to say plainly that the callback is queued for later, that the rest of the current script runs first, and that 0 means "as soon as possible", not "now".

for a middle

Explain the mechanics: the task runs to completion, the microtask queue is drained at the checkpoint, and only then is the timer callback picked up as a new task. Mention that hosts clamp small delays.

for a senior

Show when reaching for this idiom is right — releasing the thread between chunks of work, deferring until the current task has fully settled — and be honest that it cannot rescue an already-blocking loop.

for a principal

Own the tradeoff: deferring work changes observable ordering and adds latency per hop, so a codebase that sprinkles zero-delay timers to paper over ordering bugs is buying nondeterminism. Argue for fixing the data flow instead.

## What the call actually is `setTimeout` is not part of ECMAScript. It comes from the host: the HTML standard defines it for browsers and workers, and Node.js provides its own implementation. The language contributes the function value; the host contributes the timer and the queue it lands in. Calling it does three things and no more: it records the callback and the requested delay, it returns a timer handle you can pass to `clearTimeout`, and it returns control to the very next statement. The callback is not invoked, not started, not partially run. ```js console.log('A'); setTimeout(() => console.log('C'), 0); console.log('B'); // A, B, C — always ``` ## Run-to-completion comes first A JavaScript runtime executes one task at a time, and a task runs until its call stack is empty. Nothing can interrupt it — no timer, no event, no other callback. So a scheduled callback cannot possibly run "in the middle" of the function that scheduled it. The current script finishes, the stack unwinds, and only then is the runtime free to look at its queues. This is why a long synchronous loop freezes a page: the loop is one task, and the timer that was supposed to fire 0 ms ago is simply waiting its turn. ## Microtasks are drained in between Between finishing a task and starting the next one, the runtime performs a microtask checkpoint: it runs the microtask queue until it is empty. Promise reactions and `queueMicrotask` callbacks live there. Timer callbacks do not — they are ordinary tasks. The practical consequence: a `setTimeout(fn, 0)` callback runs *after* every promise continuation that was already pending, even if the timer was scheduled first. ```js setTimeout(() => console.log('timer'), 0); Promise.resolve().then(() => console.log('promise')); // promise, then timer ``` ## Zero is a floor, not a guarantee The delay argument asks for a *minimum* wait. Hosts apply a small minimum of their own — Node raises a delay below 1 ms to 1 ms, and browsers clamp zero-delay timers once they are nested several levels deep — so back-to-back `setTimeout(..., 0)` chains advance in millisecond-scale steps, not instantly. On top of that, the callback only starts once the thread is actually free, so a busy thread can delay it arbitrarily. Treating `0` as "immediately" is the single most common misreading of this API. ## Why the idiom exists Three everyday uses: 1. **Releasing the thread.** Long work is cut into pieces; each piece ends with `setTimeout(next, 0)`, so between pieces the runtime can dispatch queued input, other timers, and whatever else the host wanted to do. 2. **Deferring until the current task has fully settled.** Work that must observe the *final* state produced by the current task — after every synchronous handler and every already-queued continuation has run — is pushed to the next task. 3. **Escaping the current stack.** Code that must not run inside the caller's frame, for example so that a thrown error is not caught by the caller's `try`, gets its own task. ```js try { setTimeout(() => { throw new Error('boom'); }, 0); } catch (e) { // never reached: the callback runs in a later task, // with an empty stack above it } ``` ## What it does not do It does not create a thread; the callback runs on the same single thread as everything else, just later. It does not make the enclosing function asynchronous and it does not return a promise — it returns a timer id (a number in browsers, a `Timeout` object in Node). It does not preempt running code, so it cannot rescue a page that is already stuck in a long loop; it can only help if the loop was written to yield in the first place. And it gives no ordering guarantee against work that some *other* mechanism queues — only the structural guarantee that it lands in a later task than the one that scheduled it. ## Cancelling Because scheduling is decoupled from running, a scheduled callback may become irrelevant before it fires. Keep the handle and cancel it: ```js const id = setTimeout(render, 0); // ...conditions changed clearTimeout(id); ``` A cancelled timer never runs; calling `clearTimeout` with a stale or already-fired id is harmless.

  • Does calling setTimeout(fn, 0) make the surrounding function asynchronous or give you something to await?
    No. It returns a timer handle — a number in browsers, a `Timeout` object in Node — and the enclosing function keeps running and returns normally. If you want to await a yield you have to wrap it yourself: `await new Promise(resolve => setTimeout(resolve, 0))`. Nothing about `setTimeout` is promise-aware, and a value returned from the callback goes nowhere.
  • Why does a zero-delay timer usually fire a few milliseconds after the call rather than instantly?
    Two effects stack up. First the host applies a minimum: Node raises a sub-millisecond delay to 1 ms, and browsers clamp zero delays once timers are nested several levels deep. Second, the callback can only start when the thread is free, so any long task ahead of it pushes it back. The delay argument is a lower bound on the wait, never an upper bound.
  • If an error is thrown inside a setTimeout(fn, 0) callback, can a try/catch surrounding the setTimeout call catch it?
    No. By the time the callback runs, the code that scheduled it has already returned and its `try` block is gone; the callback executes on a fresh, empty stack in a later task. The error escapes to the host's uncaught-error handling instead. Any handling has to live inside the callback itself.

saying these in an interview costs you the question

  • Says setTimeout(0) runs the callback immediately
  • Thinks the callback can interrupt the currently running function
  • Claims a zero-delay timer beats already-pending promise callbacks
  • Believes setTimeout starts a second thread
  • Expects a surrounding try/catch to catch errors thrown in the callback

context

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 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