← Back

article / typescript node revision

How Asynchronous JavaScript Executes

nodejsasync executionUpdated

A Node server can have many requests in progress while executing ordinary application JavaScript on one thread. The apparent contradiction disappears once we distinguish executing code, waiting for an operation, and having a continuation ready to run. These are different states. A request waiting on a database does not need to keep executing JavaScript, and a completed database query does not get to interrupt whichever function currently owns the thread.

This chapter develops that distinction from the call stack through promises and scheduling. The model is one Node process with one ordinary JavaScript isolate; deliberately creating worker threads or additional processes changes the execution model. Node also has internal threads, but their existence does not give each HTTP handler its own JavaScript execution thread.

The shared call stack

Consider a synchronous chain: a() calls b(), which calls console.log(). Each call creates an execution frame on the same stack. The innermost call executes first; returning removes its frame and resumes its caller. A request handler calling calculate() behaves in the same way. Node does not automatically submit that function to a request worker pool.

function a() { b(); }
function b() { console.log("hello"); }
a();

The stack can be pictured as top-level → a → b → console.log, then unwinds in the opposite direction. Callbacks from other requests, timers, or completed I/O must wait until the current synchronous execution gives the runtime an opportunity to schedule them. The event loop cannot preempt arbitrary JavaScript, save a busy handler, and run another handler halfway through its loop.

That rule explains why a CPU-heavy handler delays unrelated clients. Marking the function async changes how its result is represented and allows suspension at await; it does not move its synchronous body to another thread. A five-second calculation before the first await still occupies the shared thread for five seconds. Asynchronous I/O is useful because the waiting happens outside that calculation, leaving opportunities for other callbacks to execute.

Practice: While this async handler executes its five-second loop, can the same isolate execute another request callback? Reason through the code rather than running the slow example.

async function handler() {
  console.log("start");
  const end = Date.now() + 5000;
  while (Date.now() < end) {
    // CPU-heavy work
  }
  await fetch("https://example.com");
}
Reveal answer

No. The loop occupies the shared JavaScript thread before the handler reaches its await. Only the asynchronous waiting phase can free that thread; marking the containing function async does not make the loop asynchronous.

Practice: /a enters while (true) {} and /b would immediately send a response. Can /b execute in this isolate? Would adding async to /a help? Reason only; do not run the infinite loop.

Reveal answer

No. The loop never releases the JavaScript thread. async does not preempt it or transfer it to a worker. The same obstruction applies to timer callbacks and promise continuations.

Promises and async await

A promise records an operation's eventual result: pending, fulfilled with a value, or rejected with a reason. It is not a thread. Calling a function that returns a promise can start work immediately; attaching .then() describes what to do after settlement. The reaction callback runs later through promise scheduling, rather than inline merely because the promise is already fulfilled.

An async function similarly begins running when called. Its caller receives a promise for the function's eventual result. At await, the function suspends and arranges a continuation for the awaited result. That suspension applies even to an already fulfilled promise: code after the await does not continue on the caller's current synchronous stack. This is the distinction formalized by ECMAScript's Await operation.

async function work() {
  console.log("B");
  await Promise.resolve();
  console.log("C");
}
console.log("A");
work();
console.log("D");

Read this as two segments of work, not as a second thread. The first segment prints B; the await ends that segment. The caller then continues to D. When the continuation is scheduled, the second segment prints C. The promise returned by work() settles when that function completes.

While a handler awaits a five-second database operation, the runtime retains the state needed to continue it: local values, the point of continuation, and its connection to the awaited promise. It does not reserve a physical request stack and leave a thread sleeping at that source line. The suspended handler is not executing request B either. B is separate work that can use the shared thread while A remains suspended.

When the database promise settles, A becomes runnable. If B is executing synchronous code at that moment, A still waits. Settlement is a scheduling event, not an interrupt. For a rejected promise, the await expression throws the rejection reason when the function resumes. A surrounding try/catch can handle it; otherwise the async function's returned promise rejects. This error-handling explanation supplements the source's successful-result examples.

Practice: What prints in work() above, and where is a database handler during its pending await?

Reveal answer

A B D C. The handler is suspended with retained state and no dedicated waiting JavaScript thread. A different request can execute if no other work blocks the shared thread. After settlement, the handler's continuation runs on that same thread.

Queues and callback order

We now have two questions: when is work eligible, and when does it actually execute? Start with a finite script that schedules a timer and a promise callback:

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

Synchronous execution prints A and D. The resolved promise makes C runnable as a microtask. At the microtask checkpoint after the current synchronous execution, it runs before the timer callback. The result is A D C B. “Sync, then microtasks, then timer” is a useful starting model for this script, but Node has several event-loop phases rather than one literal queue containing every non-microtask callback.

Promise reactions, await continuations, and queueMicrotask use the microtask queue. Draining that queue includes microtasks appended by other microtasks. A .then() chain has an additional dependency: the second callback becomes eligible when the promise returned by the first .then() settles. Writing the entire chain does not queue every link immediately.

Practice: Predict the nested-microtask variant before reading its trace.

console.log("A");
Promise.resolve().then(() => {
  console.log("B");
  Promise.resolve().then(() => console.log("C"));
}).then(() => console.log("D"));
queueMicrotask(() => console.log("E"));
console.log("F");
Reveal answer

A F B E C D. Synchronous work prints A F, leaving [B, E]. Running B appends C; returning from that callback fulfills the first reaction's returned promise and appends D. The queue is now [E, C, D], which drains in order.

The trace gives a method for larger puzzles: finish synchronous statements, write the runnable queue, then update it one callback at a time. Track when promises settle, rather than visually grouping callbacks by indentation.

Practice: Apply that method to the initial source challenge, which the transcript left unanswered.

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => {
  console.log("C");
  setTimeout(() => console.log("D"), 0);
}).then(() => console.log("E"));
queueMicrotask(() => console.log("F"));
console.log("G");
Reveal answer

Verified editorial answer: A G C F E B D for this standalone script. The initial microtasks are [C, F]. C registers timer D, then its returned promise settles and queues E behind F. Microtasks finish as C F E; timer B was registered before timer D.

Timers, Node phases, and execution context

A timer delay describes eligibility, not a reserved execution time. Registering a one-second timer does not create a JavaScript thread that sleeps for one second and then runs independently. Node tracks the deadline, and the callback must subsequently obtain the shared execution thread. A zero-delay timeout is also not immediate; Node normalizes delays below one millisecond to one millisecond. See the timer API's scheduling contract.

Practice: A one-second timer is registered before a five-second synchronous busy loop. Approximately when can it print? Could a queued promise reaction interrupt that loop?

Reveal answer

After roughly five seconds, once synchronous execution releases the thread; scheduling can add further delay. The timer was eligible earlier but could not execute. A promise microtask cannot preempt the loop either, so the explanation is thread occupation, rather than merely the timer's queue category.

Node's phases distinguish timers, certain pending callbacks, poll for I/O, check for setImmediate, and close callbacks. Promise microtasks are handled at checkpoints around JavaScript execution, rather than being another libuv phase. Since Node 20/libuv 1.45, timers run after poll instead of both before and after it. The source's phase diagram should therefore be treated as a learning scaffold, not an exact current implementation. These details and the following I/O ordering are documented in Node's event-loop guide.

Practice: In this CommonJS file's I/O callback, which prints first? Is the same relative order guaranteed if both are scheduled at top level?

const fs = require("node:fs");
fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
Reveal answer

Inside this I/O callback: immediate, then timeout. The check phase provides the immediate callback's opportunity after I/O. At top level, the relative order of these two APIs is not guaranteed; runtime timing and context matter.

Node also maintains a process.nextTick queue. Its relationship to promise microtasks depends on context. Ordinary CommonJS top-level execution ends before Node processes nextTick and then promise work. ESM module evaluation is already happening through microtask processing, which changes the observed order. Avoid memorizing a universal “nextTick always first” rule; Node's process documentation explicitly distinguishes these module contexts.

Practice: Predict this code as a .cjs file and as a .mjs file.

console.log("A");
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
setTimeout(() => console.log("timer"), 0);
console.log("B");
Reveal answer

CommonJS: A B nextTick promise timer. ESM top level: A B promise nextTick timer. Both complete synchronous statements first, but module evaluation context changes which queued work is processed next.

Why releasing the stack may still starve I/O

Returning from each callback is necessary for progress, but an endlessly replenished high-priority queue can still prevent the loop from advancing. Consider the source's promise recursion. Reason only; do not execute this infinite example.

setTimeout(() => console.log("timer"), 0);
function loop() { Promise.resolve().then(loop); }
loop();

Every call returns, so this is not ordinary synchronous recursion growing one stack until overflow. Nevertheless, each microtask adds a successor before the queue finishes draining. The timer never gets its opportunity. Recursive nextTick scheduling can similarly starve I/O and promise work. Repeatedly awaiting an already fulfilled promise is therefore not a reliable way to yield to timers or network callbacks: it can keep execution within microtask processing.

Practice: Will the timer in the example eventually print? Does the fact that each call returns prevent starvation? What if it recursively uses process.nextTick(loop) instead?

Reveal answer

No. Each promise microtask schedules another before draining finishes, so the timer never gets its opportunity even though each individual call returns. Recursive nextTick scheduling also starves I/O and can prevent normal promise work from running. Neither infinite variant should be executed.

The practical reasoning process is to identify the current synchronous segment, the operation that can suspend it, and the queue or phase in which its continuation becomes runnable. Then ask whether that queue can make progress toward the other work you care about. This explains both a responsive server awaiting I/O and an unresponsive server endlessly manufacturing runnable callbacks. Node's blocking guide connects the same distinction to request latency.

In How Node Handles Concurrent Requests, follow the operation on the other side of the await: how bytes arrive, how a driver recognizes a result, and what completion actually promises to a client.

Source: TypeScript-Inference-Practice.pdf, pp. 27–33, 40–49, 49–61. Supporting documentation clarifies current scheduling and rejection semantics; the unanswered initial puzzle's verified answer is labeled above. Back to TypeScript and Node Revision.