Skip to main content

Command Palette

Search for a command to run...

Node.js Under the Hood: Why It Is Fast and When It Is Not

Updated
11 min read
Node.js Under the Hood: Why It Is Fast and When It Is Not
S
Trying to transition my career to explore new things, new tech

I kept hearing people say Node.js is fast. In interviews, in blog posts, in random Discord threads at 2am. But nobody explained what "fast" actually meant. Fast compared to what? Fast at what? I had this vague sense that it had something to do with being "non-blocking" and "event-driven," two phrases I nodded along to without understanding for longer than I want to admit.

So I went and figured it out. The mental model that finally made it click for me was a restaurant, which I will get to in a second. But first, the thing that confused me most.


Node.js runs on one thread. That sounds terrible.

Most backend technologies like Java or Python spin up a new thread for every incoming request. Thread gets a request, does the work, sends the response, dies. Simple. If you have 1,000 requests coming in at once, you have 1,000 threads running. Each thread takes up memory and CPU time. At some point the server runs out of threads and starts queuing, or worse, crashing.

Node.js does not do this. It runs your JavaScript code on a single thread. One. That is it. When I first read that, my reaction was: how does that not immediately fall over?

The answer is in what Node does with that single thread. It never lets it sit around waiting.


The restaurant analogy

Think of a traditional multi-threaded server as a restaurant where every table gets a dedicated waiter. The waiter takes the order, walks it to the kitchen, stands at the kitchen window waiting for the food, then walks it back to the table. While the waiter is standing at the kitchen window doing nothing, they cannot help anyone else. If ten tables need service at the same time, you need ten waiters standing around staring at kitchen windows.

Node.js is the restaurant with one waiter who is extremely good at their job. The waiter takes the order from table one, hands the ticket to the kitchen, and immediately walks to table two. Takes that order, hands it to the kitchen, checks on table three. When the kitchen rings the bell saying table one's food is ready, the waiter picks it up and delivers it. The waiter never stands at the kitchen window. They are always moving, always taking the next task that is ready.

The kitchen in this analogy is the operating system doing I/O: reading files from disk, making network requests, querying databases. That work takes time, but your JavaScript thread does not need to be the one waiting for it. The OS handles it in the background, and when the result comes back, Node puts a callback in a queue. The thread picks it up when it is free.

That is non-blocking I/O. Your code says "go read this file" and moves on. It does not pause. It does not wait. It handles the next thing.

const fs = require('fs');

// non-blocking: Node hands this to the OS and moves on
fs.readFile('/some/big/file.txt', 'utf8', (err, data) => {
  // this runs later, when the file is actually read
  console.log('file contents loaded');
});

// this runs immediately, before the file is done reading
console.log('I did not wait for the file');

Output:

I did not wait for the file
file contents loaded

The second console.log runs first because readFile does not block execution. The callback fires later, when the OS finishes reading the file. If you have used fetch in the browser, you already understand this pattern. Same idea, server side.


Blocking vs non-blocking: what the difference looks like in code

Here is the blocking version of that same file read:

const fs = require('fs');

// blocking: the thread stops here until the file is fully read
const data = fs.readFileSync('/some/big/file.txt', 'utf8');
console.log('file contents loaded');

// this cannot run until the file read is completely done
console.log('now I can do other things');

Output:

file contents loaded
now I can do other things

If that file takes 500ms to read, the entire server is frozen for 500ms. No other request gets handled. Nobody gets a response. The thread is parked at the kitchen window.

In a real app with real traffic, that 500ms freeze means every user currently waiting for a response is also waiting for your file read to finish. This is why Node's standard library has async versions of almost every function. The Sync versions exist for startup scripts and CLI tools where blocking is fine because there is no one else waiting.


The event loop

The event loop is the mechanism that makes all of this work. It is a loop that runs continuously, checking: is there a callback ready to execute? If yes, run it. If no, wait for one.

   ┌───────────────────────────┐
┌─>│         timers            │  setTimeout, setInterval callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  I/O callbacks deferred to next loop
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  internal use only
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │         poll              │  retrieve new I/O events
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │         check             │  setImmediate callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │    close callbacks        │  socket.on('close', ...)
│  └─────────────┬─────────────┘
└─────────────────┘

You do not need to memorize these phases. What matters is the mental model: your code runs, hands off slow work to the OS, and the event loop picks up the results when they are ready. Everything async in Node, from reading files to database queries to HTTP requests, goes through this cycle.

Here is a quick way to see the order for yourself:

console.log('1: synchronous, runs first');

setTimeout(() => {
  console.log('4: timer callback, runs in the timers phase');
}, 0);

setImmediate(() => {
  console.log('5: setImmediate, runs in the check phase');
});

process.nextTick(() => {
  console.log('2: nextTick, runs before any phase');
});

Promise.resolve().then(() => {
  console.log('3: promise, runs after nextTick but before timers');
});

Output:

1: synchronous, runs first
2: nextTick, runs before any phase
3: promise, runs after nextTick but before timers
4: timer callback, runs in the timers phase
5: setImmediate, runs in the check phase

process.nextTick runs before the event loop continues to the next phase. Promises run right after that. setTimeout and setImmediate come later. This ordering matters when you are debugging race conditions or figuring out why a callback fires before another. Run this snippet, stare at the output, modify it. That teaches the order faster than reading about it.


Concurrency is not parallelism

This is where people get confused. Node handles many requests concurrently. It does not handle them in parallel. The difference matters.

Concurrency means managing multiple things at once. Parallelism means doing multiple things at the same time. Node is concurrent. It juggles thousands of requests by never waiting for any single one to finish. But all your JavaScript runs on one thread, so two pieces of JS code never execute at the exact same moment.

For I/O-heavy work (reading files, calling APIs, querying databases), this is completely fine. The thread spends almost no time on each request because it hands off the slow part and moves on. A single Node process can handle tens of thousands of concurrent connections this way.

For CPU-heavy work (image processing, video encoding, heavy math), this falls apart. If your code runs a loop that takes 3 seconds of pure computation, nothing else happens during those 3 seconds. The event loop is blocked. Every request in the queue sits there waiting.

// this blocks the event loop for ~3 seconds
// no request gets handled during this time
function heavyComputation() {
  let sum = 0;
  for (let i = 0; i < 3_000_000_000; i++) {
    sum += i;
  }
  return sum;
}

// while heavyComputation runs, the server is frozen
app.get('/slow', (req, res) => {
  const result = heavyComputation();
  res.json({ result });
});

// this route also stops responding while /slow is computing
app.get('/fast', (req, res) => {
  res.json({ message: 'hello' });
});

Hit /slow once and try hitting /fast in another tab. It hangs until the computation finishes. One bad route takes down the entire server.

If you need to do CPU-heavy work in Node, the answer is worker_threads. They run JavaScript in a separate thread so the main event loop stays free:

const { Worker } = require('worker_threads');

app.get('/slow', (req, res) => {
  const worker = new Worker('./heavy-computation.js');
  worker.on('message', (result) => {
    res.json({ result });
  });
  worker.on('error', (err) => {
    res.status(500).json({ error: err.message });
  });
});

The computation happens off the main thread. The event loop keeps handling other requests. Your /fast route stays responsive.


Where Node is a good fit and where it is not

Node works well for anything that is mostly I/O with a thin layer of logic on top. REST APIs, GraphQL servers, real-time apps with WebSockets, chat applications, streaming services, proxy servers. The pattern is the same: receive a request, ask some other service or database for data, send the response. The thread barely does any work itself. It is a traffic controller.

Node is a poor fit for raw computation. If your app resizes images on every request, transcodes video, runs machine learning inference, or does heavy data crunching, a single-threaded runtime is working against you. You can use worker threads, but at that point you might be better off with Go, Rust, or Python with proper multiprocessing depending on the workload.

The honest answer is that most web applications are I/O-bound. Most of the time your server is waiting: waiting for the database, waiting for an external API, waiting for a file to be read. Node was built exactly for that waiting problem.


Who actually uses Node.js in production

Netflix runs parts of their backend on Node. They switched from Java for some services and saw startup times drop significantly. Their UI layer runs on Node because server-side rendering with React was simpler there than in a Java stack.

PayPal rewrote their account overview page from Java to Node. Their team reported being able to build the Node version with fewer people in less time, and the resulting app handled more requests per second with lower average response times.

LinkedIn moved their mobile backend from Ruby on Rails to Node. They went from running 30 servers to 3, partly because Node handled the same concurrent load with fewer resources.

Uber's matching system, the part that pairs riders with drivers, runs on Node. The system needs to handle massive numbers of concurrent connections with low latency, which is exactly the kind of I/O-bound real-time workload Node handles well.

These are not small experiments. They are production systems handling millions of users. The pattern across all of them is the same: lots of concurrent connections, mostly I/O, relatively light computation per request.


Wrapping up

Node is fast because it never lets its single thread sit idle. Instead of dedicating a thread to each request and letting it wait around, Node hands off the slow work and moves on to the next thing. The event loop picks up the results when they are ready. That model lets a single process handle thousands of concurrent connections without the memory overhead of thousands of threads.

The tradeoff is CPU-bound work. Block the event loop and everything stops. Know when you are doing I/O (use async, let Node do its thing) and when you are doing computation (use worker threads or pick a different tool).

If you are building APIs, real-time features, or anything that talks to databases and external services, Node is a strong choice. Understand the event loop, keep it free, and it will handle more traffic than you expect from a single process.


References