Skip to content
AI Full-Stack Wiki

Node.js Deep Dive: Event Loop, Async I/O, Streams, and ESM

How Node.js runs your code and why it suits AI apps: the event loop, microtasks, blocking, streams with backpressure, and ES modules, with runnable examples.

Difficulty: Intermediate17 min readUpdated
On this page· One thread for your code, many for the waiting

Why this matters

An AI backend spends most of its time waiting: for a model to generate, for a vector search, for a tool's API. Node.js is excellent at waiting, because thousands of in-flight requests cost almost nothing while they wait. It is also easy to break: one CPU-heavy loop in a request handler freezes every user at once. Knowing how the event loop works is what lets you stream responses smoothly and keep latency predictable under load.

One thread for your code, many for the waiting#

Your JavaScript runs on a single main thread. Everything slow that is not JavaScript (network sockets, disk reads, DNS lookups, cryptography) is handed off to the operating system or to libuv's small thread pool. When that work completes, its callback is queued and the event loop runs it on the main thread.

How Node.js runs JavaScript and I/OYour JavaScript runs on a single thread. Network I/O is handled by the operating system and file system, DNS, and crypto work by libuv's thread pool; completed work comes back to the event loop as callbacks.scheduleYour JavaScriptone main threadEvent looplibuvOS async I/Osockets, HTTPThread poolfs, dns, crypto, zlib

Hover or tap a component to see what it does.

Node.js runs your JavaScript on one thread while the operating system and libuv's thread pool do I/O in the background, returning results as callbacks.
Show text description

This is why Node.js handles thousands of concurrent LLM requests well: waiting for a network response costs no thread. It is also why CPU-heavy work in a handler hurts every user at once.

  • Your JavaScript: Request handlers, business logic, JSON parsing. Anything slow here blocks every other request.
  • Event loop: Schedules callbacks and decides what runs next on the main thread.
  • OS async I/O: Network requests (including LLM API calls) are handled by the operating system without occupying any thread while waiting.
  • Thread pool: libuv runs blocking system work on a small pool of threads (4 by default).

The consequence is simple: waiting is free, computing is shared. Ten thousand concurrent requests to a model API consume no threads while they wait. But if one request runs a 200 ms synchronous loop, every other request waits 200 ms too.

The event loop, phase by phase#

Each turn of the loop visits a fixed sequence of phases. Most of the time you only need to know three facts:

  1. Microtasks run first. After every callback, Node.js drains process.nextTick callbacks and then resolved promises (microtasks) before moving on. That is why code after await resumes before the next timer.
  2. I/O callbacks run in the poll phase. When a network response arrives, its callback runs here.
  3. setImmediate runs right after poll; timers run at the start of the next turn.
Phases of the Node.js event loopEach turn of the event loop runs timers, pending callbacks, the poll phase for I/O, setImmediate callbacks, and close callbacks; the microtask queue drains after every callback.next turnTimerssetTimeout, setIntervalPending callbacksdeferred I/O errorsPollI/O eventsChecksetImmediateClose callbackssocket.on('close')

Hover or tap a component to see what it does.

The Node.js event loop cycles through timers, pending callbacks, poll (I/O), check (setImmediate), and close callbacks; promise callbacks run between every step.
Show text description

One thread runs your JavaScript. The event loop repeatedly visits these phases in order. After every callback, Node.js drains the microtask queue (resolved promises and queueMicrotask), which is why await continuations run before the next timer or I/O callback.

  • Timers: Runs callbacks whose timer has expired. A timer is a minimum delay, not a guarantee.
  • Pending callbacks: Some system-level callbacks (such as certain TCP errors) deferred from the previous turn.
  • Poll: Retrieves completed I/O (network, files) and runs their callbacks. If nothing is scheduled, the loop waits here.
  • Check: Runs setImmediate callbacks right after the poll phase.
  • Close callbacks: Cleanup callbacks for closed handles.

You can see the order directly. Inside an I/O callback the order is deterministic:

order.ts
import { readFile } from "node:fs";
 
readFile(import.meta.filename, () => {
  setTimeout(() => console.log("5. timeout (next turn, timers phase)"), 0);
  setImmediate(() => console.log("4. immediate (check phase)"));
  Promise.resolve().then(() => console.log("3. promise (microtask)"));
  process.nextTick(() => console.log("2. nextTick (before other microtasks)"));
  console.log("1. synchronous code in the I/O callback");
});
node order.ts
# 1. synchronous code in the I/O callback
# 2. nextTick (before other microtasks)
# 3. promise (microtask)
# 4. immediate (check phase)
# 5. timeout (next turn, timers phase)

Blocking: measure it, then move it#

The most common Node.js performance bug is synchronous work on the main thread: a large JSON.parse, a regex on a megabyte string, a tight loop computing embeddings by hand. You can measure the damage with the built-in event loop delay monitor.

lag.ts
import { monitorEventLoopDelay } from "node:perf_hooks";
 
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
 
function blockFor(ms: number) {
  const end = Date.now() + ms;
  while (Date.now() < end) {
    // Simulates CPU-heavy work: nothing else can run during this loop.
  }
}
 
setTimeout(() => blockFor(250), 100);
 
setTimeout(() => {
  histogram.disable();
  const ms = (ns: number) => (ns / 1e6).toFixed(1);
  console.log(`p50 delay ${ms(histogram.percentile(50))} ms, max delay ${ms(histogram.max)} ms`);
}, 1_000);
node lag.ts
# p50 delay 10.1 ms, max delay 250.4 ms   (exact numbers vary by machine)

A healthy server keeps the maximum delay in the low milliseconds. When you see spikes, move the heavy work off the main thread: to a worker_threads worker, to a background job (covered with queues and workers later), or to a service built for it. Calling a model API is never the problem; it is I/O.

Streams: process data as it arrives#

A stream lets you handle data chunk by chunk instead of loading all of it into memory first. For AI apps this matters twice: you read large files (documents to index) without running out of memory, and you send model output to the user while it is still being generated.

Streams and backpressureA readable source produces chunks, a transform reshapes them, and a writable destination consumes them; when the destination is slow, backpressure pauses the source.chunkschunksReadableLLM token streamTransformparse, filter, mapWritableHTTP response

Hover or tap a component to see what it does.

A stream pipeline moves data chunk by chunk from a readable source through a transform to a writable destination, with backpressure slowing the source when the destination falls behind.
Show text description

Streams let you process data without holding all of it in memory, and start sending results before the work finishes. pipeline() wires the stages together and propagates errors and backpressure for you.

  • Readable: Produces chunks over time: a file, an HTTP response, or tokens from a model.
  • Transform: Reshapes each chunk, for example turning model events into Server-Sent Events lines.
  • Writable: Consumes chunks, for example sending them to the browser.

Backpressure is the part people skip. If the destination is slower than the source (a slow client, a busy disk), chunks pile up in memory. pipeline() from node:stream/promises connects stages, applies backpressure automatically, and cleans up every stage if one fails. Async generator functions work as transform stages.

filter-tickets.ts
import { createReadStream, createWriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { createInterface } from "node:readline";
import { pipeline } from "node:stream/promises";
 
// Sample input: one JSON object per line (JSONL), a common format for datasets.
await writeFile(
  "tickets.jsonl",
  Array.from({ length: 6 }, (_, i) =>
    JSON.stringify({ id: i + 1, text: `Ticket ${i + 1}`, priority: i % 3 === 0 ? "high" : "low" }),
  ).join("\n"),
);
 
async function* onlyHighPriority(lines: AsyncIterable<string>) {
  for await (const line of lines) {
    if (!line.trim()) continue;
    const ticket = JSON.parse(line) as { id: number; priority: string };
    if (ticket.priority === "high") yield `${line}\n`;
  }
}
 
await pipeline(
  createInterface({ input: createReadStream("tickets.jsonl"), crlfDelay: Infinity }),
  onlyHighPriority,
  createWriteStream("high-priority.jsonl"),
);
console.log("wrote high-priority.jsonl");

This program would handle a 10 GB file with the same small, constant memory use, because only a few lines are in flight at any time.

Streaming an HTTP response#

The same idea, pointed at a browser. This server writes words as they are produced, respects backpressure with drain, and stops producing as soon as the client disconnects, which for an LLM app means you stop paying for tokens nobody will read.

server.ts
import { once } from "node:events";
import { createServer } from "node:http";
 
async function* words(text: string, signal: AbortSignal) {
  for (const word of text.split(" ")) {
    if (signal.aborted) return;
    await new Promise((resolve) => setTimeout(resolve, 120));
    yield `${word} `;
  }
}
 
const server = createServer(async (req, res) => {
  const controller = new AbortController();
  res.on("close", () => controller.abort()); // client went away
 
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" });
  for await (const chunk of words("Tokens arrive one by one, and so can your HTTP response.", controller.signal)) {
    if (!res.write(chunk)) await once(res, "drain"); // backpressure: wait for the socket
  }
  res.end("\n");
});
 
server.listen(3001, () => console.log("Try: curl -N http://localhost:3001"));

curl -N disables buffering so you can watch the words arrive. In the LLM App Engineering section, the source becomes a model stream and the format becomes Server-Sent Events.

ES modules in Node.js#

All examples in this wiki use ES modules. The essentials:

  • Set "type": "module" in package.json so .js and .ts files are treated as ES modules.
  • Use import / export, and top-level await anywhere in a module.
  • Use import.meta.dirname and import.meta.filename instead of CommonJS's __dirname and __filename.
  • Import Node.js built-ins with the node: prefix (node:fs, node:http) so they are never confused with npm packages.
  • Load environment variables with node --env-file=.env.local, and restart on change during development with node --watch.
  • Node.js 22.18+ runs .ts files directly by stripping types. It does not type-check, so keep tsc --noEmit (or your editor) for that, and avoid TypeScript-only runtime features such as enum.

Common mistakes#

  • Synchronous APIs in request handlers. readFileSync, execSync, and heavy JSON.parse calls block every request. Use the async versions, and move CPU work elsewhere.
  • Unhandled promise rejections. In modern Node.js an unhandled rejection crashes the process. Always await or attach .catch, and log at a top-level error handler.
  • Buffering whole responses. Collecting an entire LLM response before sending it wastes memory and makes users wait for the slowest part. Stream it.
  • Ignoring client disconnects. Without listening for close, your server keeps generating (and paying for) output after the user has left.
  • Assuming setTimeout(fn, 0) runs immediately. It runs no earlier than the next timers phase, after all microtasks and pending I/O callbacks.

Try it

Find the blocking call

  1. Run server.ts, then in a second terminal start curl -N http://localhost:3001 twice at the same time. Both streams interleave smoothly.
  2. Add blockFor(2000) (from lag.ts) before the for await loop and repeat. Explain what both clients experience and why.
  3. Replace the blocking call with await new Promise((r) => setTimeout(r, 2000)) and compare. Write one sentence in your build log about the difference between waiting and computing.

Check your understanding

Your server makes 5,000 concurrent requests to a model API. Roughly how many threads are busy while they wait?
Inside an I/O callback, which runs first: a resolved promise's then, setImmediate, or setTimeout(fn, 0)?
What does pipeline() add over manually piping streams together?

Key takeaways

  • Node.js runs your JavaScript on one thread; network waits are free, CPU work is shared by everyone.
  • Microtasks drain after every callback; I/O callbacks run in the poll phase, setImmediate right after.
  • Measure event loop delay with monitorEventLoopDelay and move CPU-heavy work off the main thread.
  • Use streams and pipeline() to process large data in constant memory, respect backpressure, and stop work when the client disconnects.

Next steps#

Finished this article? Track it to see your progress on the roadmap.