JavaScript is often described as single-threaded, yet a browser can download data while a spinner moves, and a Node.js server can keep thousands of sockets open. The apparent contradiction disappears once we separate three things: JavaScript execution, host operations, and scheduling.

One JavaScript agent normally executes one callback at a time. The browser or Node.js performs timers, network I/O, file I/O, and other operations outside that callback. When an operation can make progress in JavaScript, its continuation enters a queue. The event loop chooses when queued work may use the JavaScript thread.

This model is powerful, but it does not make arbitrary work parallel, and it does not make every asynchronous API fair. Correctness depends on knowing where a continuation is queued, when that queue is drained, and how long each callback keeps control.

The stack, queues, and run-to-completion

The call stack records active function calls. Calling a function pushes a frame; returning pops it. A thrown exception unwinds frames until a matching handler is found. While the stack is non-empty, another ordinary task cannot interrupt the running JavaScript and mutate its local state halfway through.

That property is run-to-completion. It makes a synchronous block locally atomic with respect to other tasks on the same event loop. It does not make a multi-step asynchronous operation atomic: every await can divide a function into separate turns, allowing other work to run between them.

js
let balance = 100;

async function spend(amount) {
  const observed = balance;
  await authorize(amount); // another turn may change balance here
  balance = observed - amount;
}

Two calls can both observe 100, await independently, and then overwrite one another. Single-threaded execution prevents simultaneous instructions, not logical races across suspension points.

The host coordinates several sources of work. Names differ by environment and specification, but a useful browser model has a task queue, a microtask queue, rendering opportunities, and host-managed operations. After one task finishes and the stack is empty, the browser performs a microtask checkpoint: it keeps running microtasks until that queue is empty. It may then render before selecting another task.

flowchart TD A[Select one runnable task] --> B[Run JavaScript to completion] B --> C{Microtask queue empty?} C -->|No| D[Run oldest microtask] D --> C C -->|Yes| E{Rendering opportunity?} E -->|Yes| F[Style layout and paint] E -->|No| G[Wait or select next task] F --> G G --> A

The diagram is a mental model, not a promise that all task sources share one FIFO queue. Browsers may prioritize task sources, throttle background tabs, and align rendering with display refresh. The stable ideas are run-to-completion and the microtask checkpoint before the next task.

Note

An async API does not imply another JavaScript thread. A timer is tracked by the host, and network I/O is usually handled by the operating system. The callback still runs on the event-loop thread unless the program explicitly uses a Worker or another process.

Tasks, microtasks, timers, and ordering traps

Tasks, sometimes called macrotasks, include initial script execution, timer callbacks, many DOM events, and message events. Promise reactions, queueMicrotask, and mutation observer delivery use microtasks in browsers. Microtasks have higher checkpoint priority: all currently reachable microtasks run before the next task is selected.

js
console.log('script');

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

Promise.resolve()
  .then(() => {
    console.log('promise 1');
    queueMicrotask(() => console.log('nested microtask'));
  })
  .then(() => console.log('promise 2'));

console.log('end');

In a browser, this prints script, end, promise 1, nested microtask, promise 2, then timer. The first script is one task. Its synchronous statements finish first. Resolving the first .then schedules the second reaction; the explicitly queued microtask was inserted just before it, so both run during the same checkpoint. Only after the checkpoint can the timer task run.

sequenceDiagram participant T as Script task participant M as Microtask queue participant Q as Task queue T->>T: log script and end T->>M: enqueue promise 1 T->>Q: timer becomes eligible M->>M: run promise 1 M->>M: enqueue nested microtask M->>M: enqueue promise 2 M->>M: run nested microtask M->>M: run promise 2 Q->>Q: run timer callback

setTimeout(callback, 0) means “not before the minimum delay, once the host selects this task.” It does not mean immediately. Nested timers may be clamped, inactive pages may be heavily throttled, and a busy stack or microtask queue can delay an already eligible timer. setInterval adds another risk: if work takes longer than the interval, ticks can drift or bunch according to host behavior. Recursive setTimeout is often safer because it schedules the next attempt after the current work completes.

Promises create another common trap: the promise executor runs synchronously, while .then, .catch, .finally, and the continuation after await run as microtasks.

js
console.log('A');
new Promise((resolve) => {
  console.log('B');
  resolve();
}).then(() => console.log('C'));
console.log('D');
// A, B, D, C

await value always yields its continuation, even when value is already fulfilled. Code after the await should therefore be treated as a new scheduled step, not as the rest of a synchronous transaction.

Warning

Do not use observed timer order as a synchronization primitive. Express dependencies with promises, messages, locks, or state transitions. A faster machine, a different browser, or a Node.js upgrade can expose an assumption that happened to work locally.

Browsers and Node.js share a concept, not one loop

ECMAScript defines jobs such as promise reactions, but hosts define most event-loop integration. Browsers coordinate documents, user input, networking, and rendering. Node.js builds its loop around libuv phases and operating-system I/O. The following table is deliberately about practical guarantees rather than every internal detail.

Concern Browser Node.js
Main responsibilities DOM events, networking, timers, rendering Sockets, files, subprocesses, timers
Promise microtasks Drained at microtask checkpoints Drained after JavaScript callbacks and phase transitions as defined by Node
Special high-priority queue queueMicrotask and promise jobs process.nextTick queue runs before ordinary promise microtasks
Immediate-style task MessageChannel, scheduler.postTask where available setImmediate, especially useful after I/O
Rendering step May style, lay out, and paint between tasks None in the standard server runtime
Parallel JavaScript Web Workers node:worker_threads or processes

Node’s major libuv phases include timers, pending callbacks, poll for I/O, check for setImmediate, and close callbacks. Their exact timing has evolved with libuv and Node releases. For example, the relative order of setTimeout(..., 0) and setImmediate from top-level code is not a portable guarantee. Inside an I/O callback, setImmediate normally reaches the check phase before a newly scheduled zero-delay timer reaches a later timers phase.

js
// Node.js
import { readFile } from 'node:fs';

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log('timer'), 0);
  setImmediate(() => console.log('immediate'));
});

The usual output is immediate then timer, but code should communicate required order directly rather than depend on phase trivia.

process.nextTick is not a libuv phase. Node drains that queue before ordinary promise microtasks after the current operation. It exists for compatibility and carefully staged APIs, but recursive use can prevent promises, timers, and I/O from progressing. Prefer promises for normal asynchronous composition and setImmediate when the goal is to yield to I/O.

I/O also deserves precision. Network readiness is commonly reported by the operating system. Some operations that cannot be expressed as non-blocking OS notifications, including many file-system and DNS functions, may use libuv’s worker pool. That pool performs native work; it does not automatically run the JavaScript callback in parallel. The callback returns to the main Node event loop.

Starvation, yielding, backpressure, and CPU work

A queue with priority can starve lower-priority work. This function never lets the browser select a timer or paint because every microtask schedules another microtask before the checkpoint can finish:

js
function starve() {
  queueMicrotask(starve);
}

starve();

An infinite synchronous loop is obvious, but a finite 200 ms JSON transform is enough to cause visible input lag. A long microtask chain is equally blocking. Promises defer work; they do not make CPU work cheaper.

For moderate work, split processing into bounded chunks and yield with a task, not another microtask. A time budget is more robust than a fixed item count because item cost and hardware vary.

js
const yieldToHost = () => new Promise((resolve) => setTimeout(resolve, 0));

async function indexRecords(records) {
  let cursor = 0;

  while (cursor < records.length) {
    const deadline = performance.now() + 8;
    while (cursor < records.length && performance.now() < deadline) {
      indexOne(records[cursor]);
      cursor += 1;
    }
    await yieldToHost();
  }
}

This improves responsiveness but not throughput, and setTimeout is subject to clamping. In supported browsers, scheduler.yield() provides a clearer cooperative yield. requestAnimationFrame is appropriate when the next chunk updates a visual frame. In Node, setImmediate yields after poll work and is often a better server-side boundary.

Chunking cannot rescue heavy image transforms, compression, simulation, or parsing that continually consumes a core. Move such work to a Web Worker in the browser or a Worker from node:worker_threads in Node. Workers have separate agents, stacks, and event loops; communicate through messages, transferable objects, or carefully synchronized shared memory.

ts
// Browser main thread
const worker = new Worker(new URL('./hash-worker.js', import.meta.url), {
  type: 'module',
});

worker.postMessage(largeBuffer, [largeBuffer]);
worker.addEventListener('message', ({ data }) => {
  renderDigest(data);
});

Transferring the buffer avoids copying it and detaches it from the sender. Node’s Worker has a similar message model but comes from node:worker_threads; its lifecycle and module-loading APIs differ. Worker startup and messaging have overhead, so pools are better for repeated jobs.

Concurrency also needs backpressure. If producers enqueue faster than consumers finish, the event loop remains technically alive while memory and latency grow. Bound the number of in-flight requests, pause readable streams when write() returns false, await the writer’s drain event in Node, and avoid unbounded Promise.all over large inputs. A queue limit turns overload into an explicit policy: wait, reject, shed, or degrade.

Note

Workers solve CPU contention only when work is actually moved to them. Creating a worker and then parsing the large payload on the main thread preserves the original bottleneck. Measure serialization, transfer, queue wait, and compute time separately.

Observability and deterministic testing

Event-loop failures appear as latency, not usually as exceptions. Measure both the work and the delay before it starts. In a browser, the Long Tasks API can report main-thread tasks over 50 ms, PerformanceObserver can collect entries, and performance.mark/performance.measure can surround application stages. Chrome DevTools’ Performance panel shows tasks, microtasks, rendering, and worker activity on a timeline.

js
performance.mark('parse:start');
const model = parsePayload(payload);
performance.mark('parse:end');
performance.measure('parse payload', 'parse:start', 'parse:end');

For Node.js, node:perf_hooks provides monitorEventLoopDelay() and performance.eventLoopUtilization(). Correlate those signals with request latency, queue depth, worker utilization, garbage collection, and CPU profiles. High event-loop delay with low external-service latency points toward local blocking or starvation; high queue depth with modest CPU often indicates downstream backpressure.

Tests should assert causality, not sleep and hope. Return or await the promise that represents completion. For a deliberately queued microtask, await Promise.resolve() advances one microtask boundary, but multiple nested chains may require the API’s real completion signal. Fake timers control timer clocks; they do not necessarily emulate the host’s promise queue, rendering, I/O phases, or process.nextTick semantics. Read the test runner’s timer documentation and explicitly flush the queues it virtualizes.

ts
it('publishes after persistence completes', async () => {
  const persisted = deferred<void>();
  const publishing = service.publish(article);

  expect(bus.events).toEqual([]);
  persisted.resolve();
  await publishing;

  expect(bus.events).toEqual([{ type: 'published', id: article.id }]);
});

For ordering-sensitive libraries, test in every supported runtime instead of encoding one host’s internals in a universal assertion. Add a load test that records p95/p99 event-loop delay and queue depth; unit tests rarely reveal starvation or missing backpressure.

Takeaways

  • JavaScript callbacks run to completion on one agent, but asynchronous gaps can still create logical races.
  • Tasks, microtasks, rendering, and host operations are distinct scheduling mechanisms; promise continuations run before the next ordinary task.
  • Timers provide minimum delays, not deadlines or synchronization guarantees.
  • Browsers and Node.js share event-loop principles but differ in rendering, libuv phases, setImmediate, and process.nextTick.
  • Microtask recursion and long CPU work can starve I/O and rendering. Yield through a task for moderate work and use workers for sustained computation.
  • Backpressure is part of concurrency correctness: bound queues and in-flight operations before overload turns into memory growth.
  • Observe event-loop delay, utilization, long tasks, and queue depth, then test completion signals rather than relying on sleeps or accidental ordering.