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.
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.
Hover or tap a component to see what it does.
Show text descriptionHide 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:
- Microtasks run first. After every callback, Node.js drains
process.nextTickcallbacks and then resolved promises (microtasks) before moving on. That is why code afterawaitresumes before the next timer. - I/O callbacks run in the poll phase. When a network response arrives, its callback runs here.
setImmediateruns right after poll; timers run at the start of the next turn.
Hover or tap a component to see what it does.
Show text descriptionHide 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:
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.
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.
Hover or tap a component to see what it does.
Show text descriptionHide 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.
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.
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"inpackage.jsonso.jsand.tsfiles are treated as ES modules. - Use
import/export, and top-levelawaitanywhere in a module. - Use
import.meta.dirnameandimport.meta.filenameinstead of CommonJS's__dirnameand__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 withnode --watch. - Node.js 22.18+ runs
.tsfiles directly by stripping types. It does not type-check, so keeptsc --noEmit(or your editor) for that, and avoid TypeScript-only runtime features such asenum.
Common mistakes#
- Synchronous APIs in request handlers.
readFileSync,execSync, and heavyJSON.parsecalls 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
awaitor 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
- Run
server.ts, then in a second terminal startcurl -N http://localhost:3001twice at the same time. Both streams interleave smoothly. - Add
blockFor(2000)(fromlag.ts) before thefor awaitloop and repeat. Explain what both clients experience and why. - 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
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,
setImmediateright after. - Measure event loop delay with
monitorEventLoopDelayand 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.
Related articles
- Modern JavaScript and TypeScript for AI EngineersThe JavaScript and TypeScript you need for AI apps: async/await, Promise.all, discriminated unions, Zod validation, and async iterators, with runnable code.
- Streaming LLM Responses to a React UI with Next.js and SSEStream Claude responses token by token from a Next.js Route Handler to a React client over Server-Sent Events, with stop, retry, error handling, and accessible updates.