mosaddik.dev
All posts

The Node.js event loop explained, and how not to block it

Mosaddik Billah 4 min read

JavaScript & React

A laptop showing a Node.js server file in a code editor, with incoming request logs in the terminal below
AI-generated image
On this page
  1. The restaurant analogy
  2. What the event loop does
  3. Seeing the order
  4. Blocking: the one-waiter problem
  5. How to avoid blocking
  6. Concurrency is not the same as parallel

Node.js runs your JavaScript on a single thread, yet one Node server can handle thousands of requests at the same time. The trick is the event loop. Understanding it explains why Node is fast for APIs and websites, and why one bad line of code can make a whole server stop responding.

The restaurant analogy#

Picture a restaurant with one waiter.

A slow waiter takes an order, walks it to the kitchen, stands there until the food is ready, serves it, and only then goes to the next table. Everyone else waits.

A good waiter takes an order, hands it to the kitchen, and immediately goes to the next table. When a dish is ready, the kitchen rings a bell, and the waiter delivers it between other tasks.

Node is the good waiter. Your JavaScript is the waiter: one of them. The kitchen is the operating system and Node’s background workers, which do the slow work of reading files, querying databases and waiting for network replies. The bell is the event loop telling your code “this result is ready”.

What the event loop does#

  1. Run your code from top to bottom.
  2. Whenever your code starts something slow, such as a database query, a fetch or a file read, Node hands it off and keeps going.
  3. When there’s nothing left to run, the event loop checks: has any slow task finished? Is a timer due?
  4. If so, it runs the matching callback, or continues the async function that was waiting with await.
  5. Repeat until there’s nothing left to wait for.

While a request waits on the database, Node is free to handle other requests. That’s how one thread serves many users.

Seeing the order#

JavaScript
console.log("1: start");

setTimeout(() => console.log("4: timeout"), 0);

Promise.resolve().then(() => console.log("3: promise"));

console.log("2: end");

Output:

1: start
2: end
3: promise
4: timeout

Even with a delay of 0, the timeout callback doesn’t run straight away. It waits until the current code has finished. The promise callback comes before it, because promise callbacks (and code after await) run as soon as the current code finishes, before the event loop moves on to timers and I/O.

This is the same mechanism async and await rely on.

Blocking: the one-waiter problem#

The waiter can only do one thing at a time. If your code does something that takes a long time without handing it off, nothing else can happen. No other request is answered until it finishes.

JavaScript
app.get("/report", (req, res) => {
  let total = 0;
  for (let i = 0; i < 5_000_000_000; i++) total += i; // seconds of pure CPU work
  res.json({ total });
});

app.get("/health", (req, res) => res.json({ ok: true }));

While /report is running its loop, even /health, which does nothing, can’t respond. To users the whole server looks frozen.

Common causes of blocking:

  • Heavy calculations in a request: big loops, image processing, sorting huge arrays.
  • Synchronous functions in request handlers, like fs.readFileSync or crypto.pbkdf2Sync. They’re fine at startup, not while serving requests.
  • Parsing or building very large JSON, with JSON.parse or JSON.stringify on many megabytes.
  • Slow regular expressions on user input. Some patterns take extremely long on certain strings.

How to avoid blocking#

  • Use the async versions of Node functions: await fs.promises.readFile(...) instead of fs.readFileSync(...).
  • Move heavy CPU work off the main thread with worker_threads, or into a background job that runs separately from the web server.
  • Work in smaller pieces. Process large data in batches, or stream it instead of loading it all at once.
  • Limit input size. Cap request body sizes and the amount of data one request can ask for.

Concurrency is not the same as parallel#

Node handles many requests concurrently: they’re all in progress, taking turns. It doesn’t run your JavaScript in parallel on several CPU cores by default. For I/O-heavy work, like APIs that mostly wait on databases and other services, that’s exactly what you want. For CPU-heavy work, use worker threads or run several Node processes, which most hosting platforms can do for you.

To build a server with this in mind, start with a simple REST API with Node.js and Express.

If you need a backend or API built for your site, I do custom API integration.

Comments