DEV Community

Cover image for Blocking vs Non-Blocking Code in Node.js
SATYA SOOTAR
SATYA SOOTAR

Posted on

Blocking vs Non-Blocking Code in Node.js

Hello readers 👋, welcome to the 5th blog of our Node.js Series!

In the last post, we unpacked the event loop and saw how it keeps Node.js responsive by juggling callbacks. Today we are going to look at a concept that directly affects performance: blocking vs non-blocking code. Understanding this difference is the key to writing fast, scalable Node.js applications. It's not just academic; it shows up every time you read a file or query a database.

We'll start with simple definitions, use everyday analogies, and then look at real Node.js examples that show exactly how blocking code can bring a server to its knees while non-blocking code lets it fly.

What blocking code means

Blocking code is code that stops the execution of the rest of your program until a particular operation finishes. When the main thread encounters a blocking operation, it sits there and waits. Nothing else happens during that wait: no new requests are processed, no timers fire, no callbacks run. The entire Node.js process is frozen until that operation completes.

In synchronous programming, blocking is the default. For example, consider this synchronous file read:

const fs = require("fs");

console.log("Start reading file");
const data = fs.readFileSync("big-file.txt", "utf8");
console.log("File content length:", data.length);
console.log("This runs after the file is read");
Enter fullscreen mode Exit fullscreen mode

The output is predictable:

Start reading file
File content length: 12345
This runs after the file is read
Enter fullscreen mode Exit fullscreen mode

The second console.log does not execute until the file is completely read into data. While the file is being read (maybe 50ms or more), the thread is blocked. In a script that does one thing, that's fine. But in a server handling hundreds of requests, it's a disaster.

What non-blocking code means

Non-blocking code, as we saw with the event loop, initiates an operation and then immediately moves on to the next line without waiting for the operation to finish. A callback, a promise, or an await expression later handles the result, but the main thread is never stuck.

The asynchronous version of the file read looks like this:

const fs = require("fs");

console.log("Start reading file");
fs.readFile("big-file.txt", "utf8", (err, data) => {
  if (err) throw err;
  console.log("File content length:", data.length);
});
console.log("This runs immediately, before the file is read");
Enter fullscreen mode Exit fullscreen mode

The output order is:

Start reading file
This runs immediately, before the file is read
File content length: 12345
Enter fullscreen mode Exit fullscreen mode

The main thread registers the read operation, attaches a callback, and keeps going. It never waits. When the data is ready, the event loop pushes the callback onto the stack, and the file content length is logged.

An analogy: waiting in line vs taking a buzzer

Imagine a busy food truck. There are two ways orders can be handled:

Blocking (synchronous): You place your order, and then you stand at the counter, waiting until the cook finishes your meal. The line behind you doesn't move. Only one person can be "in service" at a time. If the cook takes 3 minutes per order, 20 people will take an hour, and most of that time is spent staring at the cook.

Non-blocking (asynchronous): You place your order, pay, and receive a buzzer. You step aside and let the next person order. When your food is ready, the buzzer beeps, and you pick it up. The food truck can take orders from many people rapidly, and the cook works in parallel, while the cashier (the main thread) never gets stuck.

Blocking code is standing at the counter. Non-blocking code is taking the buzzer. In a server, every blocked request is a person stuck at the counter, and the whole queue grinds to a halt.

Why blocking slows servers

Node.js shines when it's non-blocking because its single thread can handle many concurrent connections. But if you introduce a blocking operation, that single thread is completely occupied for the duration of that operation. While it's blocked, zero other work gets done. Timers don't fire. Incoming HTTP requests wait. The entire server freezes.

Let's see a tiny HTTP server with a blocking call:

const http = require("http");
const fs = require("fs");

http.createServer((req, res) => {
  if (req.url === "/slow") {
    // Blocking file read
    const data = fs.readFileSync("huge-file.txt", "utf8");
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("File size: " + data.length);
  } else {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Fast response");
  }
}).listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});
Enter fullscreen mode Exit fullscreen mode

If you open /slow in your browser, it will take a few hundred milliseconds (depending on file size). During that time, if you open a new tab and hit the root /, you'll get no response until the /slow request finishes. The second request is sitting in the queue, waiting for the event loop, which is stuck in readFileSync. The whole server experiences a pause.

Now replace readFileSync with fs.readFile (asynchronous):

http.createServer((req, res) => {
  if (req.url === "/slow") {
    fs.readFile("huge-file.txt", "utf8", (err, data) => {
      if (err) {
        res.writeHead(500);
        return res.end("Error");
      }
      res.writeHead(200, { "Content-Type": "text/plain" });
      res.end("File size: " + data.length);
    });
  } else {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Fast response");
  }
}).listen(3000);
Enter fullscreen mode Exit fullscreen mode

Now, hitting /slow still takes time to read the file, but the server is not blocked. Requests to / get immediate responses, even while the file is being read in the background. The event loop continues to spin, serving other clients.

Real-world examples: file operations and database calls

Blocking isn't limited to fs.readFileSync. Any synchronous I/O or CPU-heavy work that hangs on the main thread is blocking. Real-world culprits include:

  • Synchronous file operations: fs.readFileSync, fs.writeFileSync, fs.existsSync.
  • CPU-intensive computations: huge loops, cryptography, image processing done on the main thread.
  • Synchronous database queries: some database drivers offer sync methods (like db.execSync()), though most Node.js drivers are async by default.

For databases, a typical non-blocking call uses callbacks, promises, or async/await. For example, with a MySQL driver:

// Non-blocking (callback-based)
connection.query("SELECT * FROM users", (err, results) => {
  if (err) throw err;
  console.log(results);
});

// The line below runs immediately after the query is sent, not after it returns
console.log("Query sent");
Enter fullscreen mode Exit fullscreen mode

Using async/await (still non-blocking):

async function getUsers() {
  const [rows] = await connection.promise().query("SELECT * FROM users");
  console.log(rows);
}
// The function suspends at await, but the main thread is free to handle other requests.
Enter fullscreen mode Exit fullscreen mode

Note: await looks synchronous, but under the hood it's non-blocking. It pauses the async function, not the entire thread. That's the beauty.

How to keep your Node.js code non-blocking

  1. Prefer asynchronous versions of modules: fs.readFile over fs.readFileSync, crypto.pbkdf2 over crypto.pbkdf2Sync.
  2. For CPU-heavy tasks, use worker threads or offload: Node.js provides worker_threads for parallelism, or you can spawn child processes, use message queues, or delegate to external services. Don't do heavy math on the main event loop.
  3. Be careful with large synchronous loops: Even a simple loop over a million items can block the thread for a noticeable time. Split work into chunks and yield control with setImmediate or setTimeout if needed.
  4. Use async/await with Promises, not sync variants: Always check if a library offers a promise-based API and use it.

Conclusion

Understanding blocking vs non-blocking code is foundational for building performant Node.js applications. The main thread is a precious resource; keep it moving. If you block it, your whole server stops. If you keep things async, the event loop can handle vast numbers of concurrent connections effortlessly.

Let's quickly recap:

  • Blocking code stops the execution of the entire program until the current operation finishes. It freezes the single thread.
  • Non-blocking code initiates an operation and immediately proceeds, handling the result later via callbacks, promises, or async/await.
  • Blocking the main thread in a server causes all other requests to queue up and wait, destroying responsiveness.
  • Real-world blocking sources include synchronous file I/O, heavy computation, and synchronous database calls.
  • Use asynchronous methods, worker threads for CPU work, and always think about the event loop.

Now that you can distinguish between blocking and non-blocking patterns, you are equipped to write Node.js code that is fast and responsive under load. In the next post, we'll explore the built-in global objects and utilities that Node.js offers to make development smoother.


Hope you found this helpful! If you spot any mistakes or have suggestions, let me know. You can find me on LinkedIn and X, where I post more about web development.

Top comments (0)