Skip to main content

Command Palette

Search for a command to run...

Blocking vs Non-Blocking Code in Node.js

Updated
•7 min read•View as Markdown

There's a moment every Node.js beginner hits where something just clicks. For a lot of people, it's when they finally understand blocking vs non-blocking code.

Because once you get this — really get it — you stop writing slow servers by accident. You start thinking about your code differently. You understand why Node.js is designed the way it is, and you stop fighting it.

Let's break it down from scratch.


The Waiting Room Analogy

Imagine you walk into a government office to get a document processed. There are two ways this office could work:

Option A: You walk up to the counter. The clerk says "wait here" and disappears into the back room for 20 minutes. You stand there. No one else can be served. The entire queue is frozen because of you.

Option B: You walk up to the counter. The clerk takes your form, gives you a token number, and says "we'll call you when it's ready." You sit down. The next person steps up. The office keeps moving. When your document is ready, they call your number.

Option A is blocking. Option B is non-blocking.

That's the whole concept. Everything else is just implementation details.


What Blocking Code Actually Means

Blocking code is code that stops everything else from running until it's done.

In Node.js, this looks like synchronous operations — things that hold the call stack hostage until they finish.

Here's the classic example — reading a file synchronously:

js

const fs = require('fs');

const data = fs.readFileSync('bigfile.txt', 'utf8');
console.log(data);
console.log("This prints after the file is read");

readFileSync is synchronous. Node.js will sit there, staring at that line, until the entire file is read into memory. Nothing else runs. No other requests get handled. Your server is frozen for however long that takes.

For a small file on a fast disk, maybe it's fine. But the moment you're reading a 50MB log file, or hitting a slow disk, or doing this inside a web server handling hundreds of concurrent requests — you've got a problem.


What Non-Blocking Code Means

Non-blocking code lets Node.js hand off the slow work and immediately move on.

Same file read, but done the right way:

js

const fs = require('fs');

fs.readFile('bigfile.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

console.log("This prints BEFORE the file is read");

Run this and you'll see "This prints BEFORE the file is read" appear first — even though it's written second. That's non-blocking in action.

Node.js hits readFile, hands it off to the OS to handle in the background, and immediately continues to the next line. When the file read finishes, the callback fires and you get your data.

Your server never froze. While that file was being read, Node.js was free to handle other requests, run other code, do whatever else needed doing.


Why Blocking Slows Your Server Down

Here's where it really matters in a real-world context.

Imagine you're building an API. A user hits your endpoint, and your handler reads a file synchronously before sending a response:

js

app.get('/report', (req, res) => {
  const data = fs.readFileSync('report.txt', 'utf8'); // blocking
  res.send(data);
});

Now imagine 100 users hit /report at the same time. User 1's request starts. readFileSync runs. The entire server freezes for however long that takes. Users 2 through 100 are just... waiting.

User 1 eventually gets their response. Then user 2's request starts. Then it freezes again.

You've accidentally built a sequential server in a platform designed for concurrency. It's like that government office where everyone has to stand at the counter waiting one by one.

Now rewrite it non-blocking:

js

app.get('/report', (req, res) => {
  fs.readFile('report.txt', 'utf8', (err, data) => {
    if (err) return res.status(500).send('Error');
    res.send(data);
  });
});

Now when 100 users hit the endpoint simultaneously, Node.js kicks off 100 file reads in parallel — all handed off to the OS — and handles each response as they come back. The server stays responsive throughout.


Database Calls: The Same Story

File reads are the textbook example, but the real culprit in most applications is database calls.

Every time you query a database, you're waiting for:

  • The query to travel over the network

  • The database to find and process the data

  • The result to travel back

That can take anywhere from 5ms to several seconds depending on your setup. If you block on every DB call, your server throughput collapses fast.

This is why every good Node.js database library — Mongoose, Prisma, pg, Sequelize — is async by default. They return Promises. They're designed to be non-blocking.

js

// Blocking mentality (don't do this)
const user = db.getUserSync(id); // hypothetical synchronous call

// Non-blocking (what you actually do)
const user = await db.getUser(id); // async, non-blocking

With await, you're not freezing the server. You're telling Node.js: "go fetch this, and when it's ready, resume this function." Everything else keeps running in the meantime.


A Side-by-Side Execution Timeline

Let's make this visual with two scenarios:

Blocking — Sequential Execution:

Request 1 arrives
→ File read starts... waiting... waiting... done
→ Response sent

Request 2 arrives (was waiting the whole time)
→ File read starts... waiting... done
→ Response sent

Total time: read1 + read2

Non-Blocking — Concurrent Execution:

Request 1 arrives → file read handed off
Request 2 arrives → file read handed off (immediately)
...both reads happening in background...
Request 1's file ready → response sent
Request 2's file ready → response sent

Total time: roughly max(read1, read2) — much faster

Same two requests. Very different server behavior.


When Is Blocking Actually Okay?

Here's the nuance: blocking code isn't always wrong.

If you're writing a CLI script — a one-off tool that runs, does something, and exits — synchronous code is often perfectly fine. There's no server to freeze, no concurrent users to worry about.

js

// In a CLI tool — totally acceptable
const config = fs.readFileSync('config.json', 'utf8');
const parsed = JSON.parse(config);

The rule of thumb: in a server handling multiple users, never block the main thread on I/O. In single-use scripts, it's your call.


The Modern Way: async/await

Callbacks work but they get messy fast. Modern Node.js code uses async/await with the fs.promises API:

js

const fs = require('fs').promises;

app.get('/report', async (req, res) => {
  try {
    const data = await fs.readFile('report.txt', 'utf8');
    res.send(data);
  } catch (err) {
    res.status(500).send('Error reading file');
  }
});

Clean, readable, and completely non-blocking. This is the style you'll see in production Node.js code.


What to Take Away

Blocking code freezes your server. Non-blocking code keeps it moving. In Node.js — which runs on a single thread — this distinction matters more than in multi-threaded environments, because there's no backup thread to pick up the slack.

The good news: the entire Node.js ecosystem is built around non-blocking patterns. Promises, async/await, callbacks — they're all tools to make sure your server never just sits there waiting when it could be doing something useful.

Once you internalize this, you'll write better Node.js code almost automatically. You'll reach for fs.readFile instead of readFileSync. You'll await your DB calls. You'll understand why Express handlers are async functions.

It's a small mental shift with a big payoff.