Event LoopGENERALBROWSER-SPECIFICDEVICE-SPECIFIC

Tasks: The Unit That Cannot Be Interrupted

Where tasks come from, why each one runs to completion, and why setTimeout(fn, 0) is neither zero nor a promise about when.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What exactly is a task, which browser activities produce one, and what does "runs to completion" cost me?

The user intent

A person expects a page that keeps up with them — a click that responds, a list that scrolls, a timer that ticks. Each of those is a task the browser has to fit into the same queue set.

The obvious build

Callbacks go into "the queue" and the browser runs them in the order they were scheduled. setTimeout(fn, 0) therefore runs fn essentially now, at the next opportunity, before anything scheduled later.

Why it breaks

There is no single queue. Each task source has its own, and the browser decides which source to service next — so a callback scheduled later from a different source can run first.

How it breaks in a real browser
  • There is no single queue. Each task source has its own, and the browser decides which source to service next — so a callback scheduled later from a different source can run first.
  • setTimeout(fn, 0) does not fire at zero. The specification requires a minimum clamp once timers are nested past a handful of levels, so a self-rescheduling zero-delay timer settles into a slow rhythm rather than a fast one (Yielding and Scheduling).
  • In a background tab, timers are throttled hard and may be coalesced into infrequent wake-ups. A polling loop built on setInterval silently stops keeping pace the moment the user switches tabs (Long-Lived Clients and Version Skew).
  • A task cannot be cancelled once it has started. clearTimeout after the callback has begun does nothing, and an AbortSignal aborted mid-task does not interrupt the running code — it only affects what happens next (Cancelling a Request Nobody Is Waiting For).
  • One slow task delays every other source. A heavy message handler from a worker delays the click that arrived while it ran, even though clicks come from an entirely different source with, in some browsers, a higher priority (Long Tasks).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A task is one callback plus its entire synchronous call tree. It begins when the browser invokes the callback and ends when the stack is empty again. There is no yield point inside it that the browser controls.
  • The specification names task sources — the timer source, the user interaction source, the DOM manipulation source, the networking source, the history traversal source, the posted-message source and others. Each source maps to a queue, and each queue is FIFO.
  • The loop picks one runnable queue per turn. Which one is implementation-defined, and browsers use that freedom: input and rendering-adjacent work is commonly favoured so a click is not stuck behind a backlog of timers.
  • Run-to-completion is a correctness feature, not an oversight. Because nothing preempts a task, you never need a lock to protect a data structure from another JavaScript frame mutating it halfway through (Data Race Is Not Race Condition does not apply here — there is one thread and no interleaving of your statements).
  • Timers are a request, not a promise. setTimeout(fn, d) means "no sooner than d, and only once the thread is free"; a busy thread turns a precise-looking delay into an arbitrary one.
  • Some things that feel like your code are tasks the browser queued on its own behalf: parsing a chunk of streamed HTML, running a deferred script, delivering a message from a worker, and firing an event you did not register (Streaming HTML).

What this makes the browser do

And which of it is avoidable.

  • Keeping one queue per task source and a timer structure that has to be consulted every turn to see whether anything has come due.
  • Applying throttling and alignment policy to timers by page visibility and, on some platforms, by power state — which is real work, and also a real behaviour change your code has to tolerate.
  • Dispatching an event, which is more expensive than it looks: hit-testing the pointer position, building the propagation path, running capture, target and bubble phases, and applying the default action (How an Event Is Dispatched).
  • Queueing tasks for its own machinery — parsing, script evaluation, resource loading callbacks — which compete with yours for the same turns (Why a Script Tag Stops the Parser).

Where tasks come from

The specification names task sources so that ordering can be guaranteed where it matters and left free where it does not. Within one source, order is guaranteed. Across sources, it is the browser's call — and browsers use that latitude to keep input responsive.

The practical value of knowing the sources is that it tells you which guarantees you have. Two clicks are ordered. Two timers are ordered. A click and a timer are not.

  • Parsing, script evaluation and resource-load callbacks also occupy turns of the loop, and on a page that is still loading they are the majority of them (`defer`, `async` and `type="module"`).
  • Rendering is not a task. It is a separate phase at the end of a turn, which is why you cannot "queue a repaint" (The Rendering Opportunity).
Task sourceTypical originOrdering guaranteeWhat surprises people
TimersetTimeout, setIntervalFIFO among timers due at the same momentClamped when nested, throttled when hidden
User interactionclick, keydown, pointer eventsFIFO per interactionOften prioritised above other sources, but not required to be
DOM manipulationevents the DOM fires on its own behalfFIFOSome DOM events are dispatched synchronously instead, inside your task
Networkingfetch and XHR completionFIFO per requestThe task delivers the response; your .then is a microtask after it
Posted messagepostMessage, MessageChannel, worker messagesFIFONot clamped like timers, which makes it a yielding primitive (Talking to a Worker)
History traversalback and forward navigationFIFOA popstate handler competes with everything else for the thread (History and Navigation)

Zero is not zero

The timer API looks like a scheduling primitive with millisecond precision. It is better understood as a lower bound with two independent sources of delay stacked on top: the clamp the specification requires for nested timers, and the queueing delay imposed by whatever else is on the thread.

The second is usually much larger than the first. A timer requested at the start of a long task cannot fire until that task ends, no matter what delay was asked for — which means the delay you observe is dominated by the code you did not write in the timer call.

Repeating work on a timer
Interval, trusting the tick
let elapsed = 0;
setInterval(() => {
  elapsed += 1000;          // "one second has passed"
  render(remaining - elapsed);
}, 1000);
Self-rescheduling, reading the clock
const startedAt = performance.now();
function tick() {
  const elapsed = performance.now() - startedAt;
  render(remaining - elapsed);
  if (elapsed < remaining) setTimeout(tick, 1000);
}
tick();
document.addEventListener("visibilitychange", () => {
  if (!document.hidden) tick();   // re-anchor after throttling
});

The first counts invocations and calls that time; if the tab is hidden, throttled, or simply busy, the count and the clock diverge without bound and the countdown is wrong. The second derives elapsed time from a clock that keeps running regardless of how the browser scheduled the callback, and cannot queue a backlog because the next timer is only requested once the previous one has run.

Run-to-completion, and what it does not protect you from

Run-to-completion is the reason frontend code needs no mutexes. Between the first and last statement of a task, no other JavaScript on that page runs — the DOM cannot be mutated underneath you, and no other handler can observe a half-updated object.

It is also the reason the page freezes. The same property that makes your code atomic makes it unpreemptable, and the browser has no way to take the thread back to service a click. Every failure row below is that trade playing out.

Task-shaped failures
TriggerSymptomCauseResponse
A handler iterates a large collection synchronouslyClicks during the loop do nothing, then all apply at onceInput events queue behind a task that cannot be interruptedChunk the work across tasks, or move it to a worker (When a Worker Is Actually the Answer).
setInterval callback slower than the intervalBursts of work; the UI stutters in a rhythmTicks queue while the previous one runs, then run back to backSelf-reschedule with setTimeout at the end of the work.
Tab backgrounded for a long periodCountdown wrong, poll stale, session expires unannouncedTimer throttling and possible process suspensionReconcile against performance.now() or the server on visibilitychange.
Nested zero-delay timers used as a work loopProgress far slower than expectedThe nested-timer clamp applies after a few levelsUse MessageChannel or a scheduler API for unclamped yielding (Yielding and Scheduling).
Stale response handled after the DOM was replacedContent from an abandoned view appearsThe task was already queued; abort does not unqueue itTag the request and check relevance inside the handler (Out-of-Order Responses).

How to build it

Most important first.

  • Size your tasks deliberately. The question is never "is this function fast" but "how long does the loop stay inside it", because that duration is the user's worst-case wait for anything at all (Interaction Responsiveness).
  • Use setTimeout for "later, after the browser has had a chance to breathe" — not for precise timing. If you need timing, drive it from requestAnimationFrame for visual work or from a timestamp difference for logical work, not from an accumulating count of intervals.
  • Prefer a single self-rescheduling setTimeout over setInterval for repeated work. setInterval will happily queue the next tick while the previous one is still running, producing a backlog you did not ask for.
  • When you need a task boundary with no clamp, MessageChannel posts to a different source and is not subject to timer clamping — a real technique with a real cost in readability (Yielding and Scheduling).
  • Assume the tab will be backgrounded and the timers will stop keeping time. Reconcile against the wall clock or the server on visibility change rather than trusting an accumulated tick count (State Synchronization).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Assistive-technology interaction arrives through the same task queues as any other input. A busy thread delays a screen reader's virtual cursor movement and a switch device's activation exactly as it delays a mouse click (Keyboard Operability).
  • Because a task cannot be interrupted, there is no way for the browser to service an accessibility query mid-task. The tree is whatever it was when the task started, for the full duration of the task (The Accessibility Tree).
  • Timer-driven UI that assumes a steady tick — a countdown, an auto-advancing carousel, a session-expiry warning — behaves unpredictably after throttling. For a user who needs more time, an inaccurate countdown is a functional barrier, so give a way to extend or disable it.
  • Never use a short timer to move focus "after the DOM updates" and hope. Sequence it explicitly, because a delayed focus move that lands after an announcement has begun leaves the user oriented to the wrong place (Focus Management).

What can go wrong

Failure modes
  • A setInterval whose callback occasionally takes longer than the interval. Ticks pile up, and when the thread frees the browser runs the backlog nose-to-tail, producing a burst of work exactly when the page was already struggling.
  • A polling loop that assumes elapsed time equals ticks multiplied by interval. After a background period the two disagree by an unbounded amount.
  • A "debounce" implemented with a timer whose callback does expensive work. The debounce controls how often the task is queued, not how long it occupies the thread once it runs (The Real Cost of JavaScript).
  • Cancelling a request but not the work it triggers. Aborting a fetch does not unqueue the task that will process a response already in flight, so the handler still runs and must check whether it is still relevant (Out-of-Order Responses).
  • Relying on cross-source ordering that happens to be stable in your development browser and inverts in another engine.
What can arrive out of order
  • Two tasks from different sources — a timer and a network callback, an input event and a message from a worker — have no specified relative order.
  • A setInterval callback can be queued while the previous one is still running, so two logical ticks can land back to back with no gap.
  • An event that arrives during a long task is dispatched after it, by which time the DOM the handler assumes may already have been replaced by the task that was running (Node Identity Across Updates).
  • A timer scheduled before a tab was hidden and a timer scheduled after it was shown are subject to different throttling, so their relative firing order can invert across a visibility change.
Security
  • Timer resolution is intentionally coarsened and jittered by browsers to blunt timing side channels, so a task boundary is a poor primitive for anything that needs a precise clock.
  • Run-to-completion means a task holds the thread for as long as it wants. A third-party script can therefore degrade your page's responsiveness without exploiting anything at all (Third-Party Scripts and the Supply Chain).
  • A guard implemented as "queue the real work in a timer after checking permission" is weaker than it looks: state can change between the check and the task. Do the check where the action happens, and enforce it on the server (Authorization-Aware UI).
Misreads
  • "setTimeout(fn, 0) runs fn immediately after the current function." It runs on a later turn of the loop, after the microtask checkpoint, after any rendering the browser decides to do, and after any earlier task in a queue the browser chooses to service first.
  • "There is one queue and it is fair." There are several, and fairness across them is explicitly the browser's choice.
  • "Run-to-completion means my code is safe from all races." It means your statements do not interleave. It does not mean the world stopped: the DOM, the server and other tabs can all have changed between two of your tasks (Out-of-Order Responses).
  • "Cancelling means stopping." Cancellation in the browser almost always means "do not start the next thing", never "unwind the thing that is running" (Cancelling a Request Nobody Is Waiting For).

Measuring it, and what changes in the field

How you would see this
  • The Performance panel's main-thread track shows each task as a top-level block, and browsers commonly flag ones that overrun. The label on the block tells you the source: a timer, an event, a parse (A Mental Model of the Devtools).
  • A PerformanceObserver on long-task entries gives you the same signal in the field, without a devtools window open (Real User Monitoring).
  • For timer drift specifically, compare performance.now() deltas against the nominal interval. The gap is the throttling and the queueing, made visible.
Slow device, slow network, large data, old tab
  • On a slow device every task is longer, so the same number of queued tasks translates into a longer worst-case wait for the next one.
  • In a hidden tab, timers are throttled and rendering opportunities largely stop; the page keeps running but on a completely different schedule.
  • On a page with many third-party scripts, your tasks are a minority of the tasks on the thread, and your worst-case latency is set by the slowest thing anyone shipped (Third-Party Scripts and the Supply Chain).
  • With a large dataset, a single per-item task becomes a very large number of very small tasks, and the per-task overhead itself starts to matter.
What this costs
  • Breaking work into more, smaller tasks improves responsiveness and makes total elapsed time worse. Each hand-off costs, and the browser may run other work in between.
  • MessageChannel avoids timer clamping at the price of an idiom most readers of your code will not recognise, and of losing the throttling behaviour that background tabs rely on to save battery.
  • Defensive reconciliation against the wall clock is more code and more edge cases than trusting an interval. It is also the difference between a session timer that works after a lunch break and one that does not.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALTask sources, FIFO ordering within a source and run-to-completion are specified in the HTML standard and hold everywhere; so does the nested-timer clamp, which is a specification requirement rather than an implementation quirk.
  • BROWSER-SPECIFICBackground-tab timer throttling policy is not specified: Chromium progressively throttles and then aligns timers in hidden tabs and adds further restrictions for tabs hidden a long time, Safari applies its own aggressive policy tied to power state, and Firefox uses a different budget-based scheme — so the observed tick rate of a background timer differs by browser and by platform.
  • DEVICE-SPECIFICOn battery-constrained mobile devices the operating system may suspend the whole page process, which no amount of timer scheduling survives; the same code on a plugged-in desktop keeps ticking, which is why background-timer bugs reproduce only on phones.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — the host defines task sources and timers; the language defines only the job queue that microtasks use.