Under the Hood
Eventloop

Tasks and the task queue: timers, events, and the 4ms clamp

The event-loop lesson's "task queue" was a black box holding whatever runs next — this lesson opens it up to show exactly what lands there (timers, DOM events, I/O completions), why setTimeout's delay is a minimum and never a guarantee, and the 4ms clamp that kicks in once timers nest deep enough.

Tasks and the task queue: timers, events, and the 4ms clamp

The event-loop lesson described the task queue as "a line of pending pieces of work waiting to run," and the call-stack lesson pinned down what happens once a task starts — one frame pushed, run to completion, popped, stack empty. This lesson is about the other side: what actually lands in that queue, where it comes from, and the timer behavior that trips people up constantly — a setTimeout delay is a floor, not a promise, and nested timers get throttled in a way most code never accounts for.

What actually goes into the queue

A task (sometimes called a "macrotask" to distinguish it from a microtask) is a unit of work the browser schedules to run on the main thread, in its own turn, from its own start. The queue doesn't hold running code — it holds entries, each one a function ready to become the next thing pushed onto an empty stack. The sources that produce these entries are all outside your synchronous code:

  • Timers — a setTimeout or setInterval callback, enqueued once its delay has elapsed.
  • DOM events — a click, keydown, scroll, or similar event enqueues a task to run its handler. The browser doesn't run your onclick function in the middle of whatever else is executing; it waits, enqueues a task, and the loop picks it up on some future turn, same as a timer.
  • message events — cross-context communication, notably postMessage between a page and a Web Worker, arrives as a task.
  • I/O completions — a finished file read, a completed network request at the platform level (as distinct from the promise machinery fetch layers on top, which is a microtask concern covered separately).

Whatever the source, the shape is identical: something happens outside your JavaScript, the browser packages "run this callback" as a task, and it goes into a queue to wait for the loop.

One task per turn, then the rest of the machinery

The event loop's rule, restated precisely for tasks: pull one task off the queue, run it to completion (push its frame, let the call stack do everything under it, wait for the stack to empty), and only then consider what's next. What's next is not simply "the next task" — after every task, the loop fully drains the microtask queue, and at appropriate points it also runs a rendering step. Both of those are their own lessons; the fact to hold here is narrower and simpler: tasks are handled one at a time, never two in parallel, and never a fraction of one at a time. A task from a click and a task from a timer never interleave — whichever is pulled off the queue runs completely before the other is even considered.

Within a single source, tasks come out in the order they went in — first in, first out. Two clicks queue two tasks and the earlier click's handler runs first. But browsers actually maintain multiple task queues internally (one per source is a reasonable mental picture), and the spec allows the browser to prioritize between them — user input is commonly given priority over, say, a background timer, so that the page feels responsive to touch even if other tasks are backed up. None of that changes the core model: the loop still picks exactly one task, runs it to completion, and repeats. "Which queue it came from" is a prioritization detail; "one at a time, to completion" is the invariant that never bends.

setTimeout's delay is a minimum, not a guarantee

Here is the detail that catches almost everyone at some point: setTimeout(fn, delay) does not mean "run fn in exactly delay milliseconds." It means "once at least delay milliseconds have elapsed, and once the call stack is empty, make fn eligible to be pulled off the queue." Those are two separate conditions, and the second one is exactly the run-to-completion rule from the previous lesson: a task can only start when the stack is empty, and if the stack is busy running something else, the timer's callback simply waits — no matter how long ago its delay elapsed.

console.log('start');

setTimeout(() => {
  console.log('timer fired');
}, 100);

const start = performance.now();
while (performance.now() - start < 500) {
  // busy for 500ms — the stack never empties
}

console.log('end of synchronous work');
// 'timer fired' logs here, at roughly 500ms in, not 100ms —
// the callback was eligible at 100ms but the stack was still occupied.

The timer elapsed at 100ms. Its callback did not run at 100ms, because the synchronous loop was still holding the stack — the same "one frame, refusing to pop" situation from the call-stack lesson. The callback had to wait for that frame to finish, then wait its turn in the queue, and only then get pulled onto an empty stack. A long task doesn't just block whatever handler happens to be running when it starts; it delays every pending timer, because every one of them is gated on the same fact: the stack has to be empty before any of them can start.

The 4ms clamp

There's a second timer wrinkle, and it's a deliberate spec rule rather than an accident of implementation. The HTML spec requires that once setTimeout (or setInterval) calls are nested more than a handful of levels deep — a timer set from inside a timer's own callback, repeated roughly five times or more — every further nested timer has its requested delay clamped to a minimum of 4 milliseconds, even if you asked for 0.

function tick(depth) {
  console.log(`depth ${depth}`, performance.now());
  if (depth < 8) {
    setTimeout(() => tick(depth + 1), 0); // asking for 0ms every time
  }
}
setTimeout(() => tick(0), 0);

// The first few nested calls fire back-to-back, close to 0ms apart.
// Somewhere around depth 5, the gap between consecutive logs climbs
// to roughly 4ms and stays there — the clamp has kicked in.

The reason the clamp exists is worth internalizing rather than memorizing: a zero-delay timer that re-schedules itself is, structurally, an infinite loop dressed up as async code. Without a floor, a page could nest setTimeout(fn, 0) calls and peg a full CPU core running "instant" timers back to back, forever, while looking — to a casual read — perfectly asynchronous and cooperative. The 4ms floor doesn't stop that pattern from running, but it guarantees it can never run faster than roughly 250 times a second, which caps the damage a runaway zero-delay recursion can do to the rest of the page's responsiveness. Background tabs get an even harsher version of the same idea — browsers commonly clamp timers in hidden tabs to a much coarser interval (on the order of once per second), specifically so an inactive tab can't keep burning CPU animating or polling something nobody is looking at.

setInterval's real risk: it doesn't wait for you

setInterval(fn, period) enqueues a task every period milliseconds, on a fixed schedule, regardless of whether the previous callback has finished running. If fn sometimes takes longer than period to run — a genuine risk any time fn's cost depends on data size or network timing — the next task can be sitting in the queue, or arriving right after the previous one finally clears, with no gap between them. The scheduler has no way to skip a stale invocation or feel out how long the last one took; it just keeps stamping a new task onto the queue at the interval you asked for. Do this enough and callbacks pile up and start running back to back with zero pause, which reads as the interval silently speeding up — it isn't speeding up, its backlog is draining, but the pattern looks the same from outside.

The common fix is to stop trusting a fixed interval and instead have each call schedule the next one with setTimeout, only once the current call has actually finished:

function poll() {
  doWork();
  setTimeout(poll, 1000); // schedules the next call only after this one is done
}
setTimeout(poll, 1000);

This guarantees a full 1000ms gap between the end of one call and the start of the next, no matter how long doWork took — there's no possibility of two calls overlapping or queuing up back to back, because the next task is never enqueued until the current one has already run to completion.

The mental model holds

Timers, events, messages, I/O — different sources, wildly different real-world timing, but they all funnel into the same queue and the same rule: the loop picks one task, runs it to completion on the call stack, and only then looks at what's next. Everything unusual in this lesson — a 0ms timeout that doesn't fire at zero, a 100ms timer delayed to 500ms by a busy stack, a nested-timer clamp at 4ms — is a consequence of that one rule, not an exception to it. "Pick a task, run it to completion" is still the whole model; this lesson just filled in what's actually sitting in the queue, and how honest that "delay" argument really is.

Where this goes next

Tasks are only half of what happens between one empty stack and the next: every time the stack empties, before the loop even considers the next task, it drains an entirely separate, higher-priority queue first. Microtasks and the checkpoint covers that queue — where Promise callbacks actually go, why they run before any timer no matter how small its delay, and what "the checkpoint" precisely refers to.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Name four distinct sources that enqueue a task, and describe in one sentence each what triggers the enqueue.
  2. Why is 'one task per turn' still true even though browsers maintain multiple internal task queues and may prioritize between them?
  3. Explain precisely why setTimeout(fn, 100) can fire well after 100ms have elapsed, tying your answer back to run-to-completion.
  4. What exactly does the 4ms clamp apply to, at what nesting depth does it kick in, and why does the spec impose a floor instead of leaving zero-delay timers uncapped?
  5. Why can setInterval callbacks end up running back to back with no gap, and how does recursive setTimeout avoid that failure mode?
  6. What does 'FIFO within a source' mean for two queued click-handler tasks versus a click task and a timer task queued around the same time?
  7. Someone claims setTimeout(fn, 0) runs fn immediately, before the next line of code. Using the task queue model, explain exactly what's wrong with that claim.