Skip to main content

Command Palette

Search for a command to run...

My Server Was Freezing and I Did Not Know Why. Then I Learned What Blocking Means.

Updated
9 min read
My Server Was Freezing and I Did Not Know Why. Then I Learned What Blocking Means.
S
Trying to transition my career to explore new things, new tech

I had a Node.js server running. A simple Express app, maybe four routes. One of those routes read a JSON file from disk and sent it back as a response. Worked fine when I tested it alone. Then I opened two browser tabs and hit the route at the same time. The second tab waited. Not for a network reason, not because my machine was slow. It waited because the first request had not finished reading the file yet, and my server was doing absolutely nothing else in the meantime.

I had written blocking code without knowing what that meant.

This took me an embarrassingly long time to understand, partly because every explanation I found started with the event loop and callback queues and microtask priorities. I did not need any of that yet. I needed to understand one thing: when your code blocks, your entire server stops.


What blocking code actually looks like

Blocking means the program stops on one line and refuses to move to the next line until that operation finishes. The CPU sits there, waiting. Nothing else runs.

Here is the Node.js version of shooting yourself in the foot:

const fs = require('fs');

console.log('before reading file');

const data = fs.readFileSync('./bigfile.json', 'utf-8');

console.log('after reading file');
console.log('doing other stuff');

Output:

before reading file
// ... long pause while the file loads ...
after reading file
doing other stuff

readFileSync. That Sync at the end is the giveaway. It means synchronous. The program reaches that line, stops everything, waits for the entire file to load into memory, and only then moves on. If that file is 500MB, every line of code below it waits for 500MB to finish loading. If another user hits your server during that wait, they get nothing. The server is busy staring at a hard drive.

For a script you run once on your own machine, this is fine. For a server handling multiple users, it is a problem.


The restaurant analogy

Think about a restaurant with one waiter. A customer orders a steak. The waiter walks to the kitchen, hands in the order, and then stands there watching the chef cook. Does not take any other orders, does not refill anyone's water, does not greet new customers walking in. Just stands at the kitchen window, arms crossed, waiting for the steak.

That is blocking.

Now picture the same waiter, same restaurant. Customer orders a steak. The waiter hands the order to the kitchen and immediately walks back to the floor. Takes another order. Refills a drink. Seats a new table. When the kitchen rings the bell to say the steak is ready, the waiter picks it up and delivers it.

That is non-blocking.

The kitchen still takes the same amount of time to cook. The steak is not faster. But the waiter is not frozen in place while waiting for it, so every other customer in the restaurant gets served in the meantime.

Node.js is that single waiter. It has one thread. If you make it stand around waiting for a file to load or a database to respond, nobody else gets served.


What non-blocking code looks like

The non-blocking version of the same file read uses a callback:

const fs = require('fs');

console.log('before reading file');

fs.readFile('./bigfile.json', 'utf-8', (err, data) => {
  if (err) {
    console.error('read failed:', err.message);
    return;
  }
  console.log('file loaded, length:', data.length);
});

console.log('not waiting around');
console.log('doing other stuff');

Output:

before reading file
not waiting around
doing other stuff
file loaded, length: 4893722

Look at the order. "not waiting around" prints before the file finishes loading. The program did not stop. It told the operating system "read this file and call me back when you are done," then immediately moved to the next line. When the file finished loading, the callback function ran.

The file still took the same amount of time to read. But the rest of the program did not freeze while it happened.


Why this matters for servers

Here is where it gets real. Put blocking code inside a route handler and watch what happens to every other request.

const express = require('express');
const fs = require('fs');
const app = express();

// bad: blocking route
app.get('/data', (req, res) => {
  const data = fs.readFileSync('./bigfile.json', 'utf-8');
  res.json(JSON.parse(data));
});

// this route works fine on its own
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(3000);

If someone hits /data and the file takes 3 seconds to read, every request to /health during those 3 seconds also waits 3 seconds. The health check has nothing to do with the file. It should return instantly. But Node.js has one thread, and that thread is stuck on readFileSync. Everybody waits.

The fix:

// good: non-blocking route
app.get('/data', async (req, res) => {
  try {
    const data = await fs.promises.readFile('./bigfile.json', 'utf-8');
    res.json(JSON.parse(data));
  } catch (err) {
    res.status(500).json({ error: 'could not read file' });
  }
});

Same result. Same file. But now while the file loads, Node.js goes back to handling other requests. The /health route responds instantly regardless of what /data is doing.


The timeline, visually

Here is what happens with blocking code when two requests come in:

User2 asked for a health check. A response that takes 0 milliseconds to generate. They waited 3 seconds because the server was stuck reading a file for someone else.

Now the non-blocking version:

User2 gets their response immediately. User1 still waits 3 seconds for the file, because the file is still big. But they are no longer holding the entire server hostage.


Callbacks, promises, async/await

Node.js has gone through three eras of writing non-blocking code. All three still work. You will see all three in other people's codebases.

Callbacks came first. You pass a function that runs when the async work finishes:

fs.readFile('./config.json', 'utf-8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

The problem with callbacks shows up when you need to do three async things in sequence. Read a file, then use its contents to query a database, then use that result to send an email. Each step nests inside the previous callback. The code drifts to the right and becomes genuinely hard to follow. People call this callback hell, and the name is earned.

// callback hell
fs.readFile('./config.json', 'utf-8', (err, config) => {
  if (err) return handleError(err);
  db.query(config.query, (err, rows) => {
    if (err) return handleError(err);
    sendEmail(rows[0].email, 'hello', (err, result) => {
      if (err) return handleError(err);
      console.log('done');
    });
  });
});

Promises fixed the nesting:

fs.promises.readFile('./config.json', 'utf-8')
  .then(config => db.query(JSON.parse(config).query))
  .then(rows => sendEmail(rows[0].email, 'hello'))
  .then(result => console.log('done'))
  .catch(err => handleError(err));

Flat. Each .then() returns a new promise. Errors fall through to .catch(). Easier to read, easier to reason about.

Async/await made it look like regular code:

async function handleRequest() {
  try {
    const raw = await fs.promises.readFile('./config.json', 'utf-8');
    const config = JSON.parse(raw);
    const rows = await db.query(config.query);
    await sendEmail(rows[0].email, 'hello');
    console.log('done');
  } catch (err) {
    handleError(err);
  }
}

This is still non-blocking. The await keyword does not freeze the server the way readFileSync does. When execution hits await, Node.js pauses that specific function and goes to handle other work. When the awaited operation finishes, it comes back and picks up where it left off. Other requests keep flowing.

If you are starting fresh, use async/await. It reads like sequential code but behaves like non-blocking code. The callbacks and promise chains are worth recognizing because you will run into them in older projects, but for new code, async/await is the standard.


A real-world example: database calls

File reading is one thing, but the same blocking problem applies to anything that takes time. Database queries are the most common culprit in real applications.

// imagine a route that fetches user data and their orders
app.get('/user/:id/summary', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    const orders = await Order.find({ user: req.params.id }).limit(10);

    res.json({
      name: user.name,
      email: user.email,
      recentOrders: orders,
    });
  } catch (err) {
    res.status(500).json({ error: 'something broke' });
  }
});

Both findById and find are async. While MongoDB processes the query, Node.js handles other requests. No one is stuck waiting.

But notice something: the user query finishes before the orders query starts. They do not depend on each other. You could run them at the same time:

app.get('/user/:id/summary', async (req, res) => {
  try {
    const [user, orders] = await Promise.all([
      User.findById(req.params.id),
      Order.find({ user: req.params.id }).limit(10),
    ]);

    res.json({
      name: user.name,
      email: user.email,
      recentOrders: orders,
    });
  } catch (err) {
    res.status(500).json({ error: 'something broke' });
  }
});

Promise.all fires both queries at the same time and waits for both to finish. If the user query takes 50ms and the orders query takes 80ms, the total wait is 80ms instead of 130ms. For two queries the difference is small. For five or six independent async operations, it adds up fast.


Common mistakes and how to avoid them

I am putting these here because I made every single one of them.

Using Sync methods in a server. Any function ending in Sync blocks the entire process. readFileSync, writeFileSync, execSync. These are fine in a CLI script or a build tool. They have no place in a server handling requests. If you see Sync in server code, replace it with the async version.

Forgetting try/catch around await. An unhandled promise rejection used to just log a warning. In newer versions of Node.js it crashes the process. Wrap your awaits in try/catch or use an error-handling middleware in Express.

Running independent async operations in sequence when they could run in parallel. If operation B does not depend on the result of operation A, use Promise.all. The code runs faster with no extra complexity.

Not understanding that await pauses the function, not the server. I see beginners avoid await because they think it "blocks." It does not. It pauses that one function and frees the thread for everything else. That is the whole point. Use it.


If you want to go deeper on how Node.js actually handles all of this under the hood, here are solid resources.