Skip to content
Node.js nd async 4 min read

Synchronous vs Asynchronous Programming

Every line of code you write runs in one of two ways: synchronously, where each operation finishes before the next begins, or asynchronously, where slow work is started now and its result handled later. Node.js runs your JavaScript on a single thread, so understanding this distinction is the difference between a server that handles thousands of concurrent requests and one that grinds to a halt under load. This page explains what blocking really means, why it is dangerous in Node, and how a single thread can still feel massively concurrent.

Synchronous (blocking) execution

Synchronous code runs top to bottom, one statement at a time. A statement that takes a long time to complete holds up everything behind it — nothing else can run until it returns. This is called blocking because that single operation blocks the thread.

import { readFileSync } from "node:fs";

console.log("before");
const data = readFileSync("./report.txt", "utf8"); // blocks here
console.log("after", data.length);

Output:

before
after 2048

The readFileSync call stops the program until the entire file has been read from disk. For a small script that is harmless. In a server handling many users at once, it is a problem: while that one read is in progress, no other request can be served.

Asynchronous (non-blocking) execution

Asynchronous code starts a slow operation and immediately moves on. When the operation completes, Node notifies your code through a callback, a Promise, or await. The thread stays free to do other work in the meantime.

import { readFile } from "node:fs/promises";

console.log("before");
const data = await readFile("./report.txt", "utf8"); // does not block the thread
console.log("after", data.length);

Output:

before
after 2048

The console output looks identical, but the mechanics differ completely. The await here suspends only the current async function — the underlying thread is released to handle other events while the disk does its work. Multiple reads can be in flight at the same time.

How a single thread stays concurrent

Node.js does not spawn a new OS thread per request. Instead it runs your JavaScript on one thread driven by the event loop, and delegates slow I/O (disk, network, DNS) to the operating system or to a small background thread pool provided by libuv. While that I/O runs outside your JavaScript thread, the event loop is free to pick up the next task. When an operation finishes, its callback is queued and run when the call stack is clear.

import { readFile } from "node:fs/promises";

// Three reads start nearly at once; the thread is never blocked waiting.
const files = ["a.txt", "b.txt", "c.txt"];
const results = await Promise.all(files.map((f) => readFile(f, "utf8")));
console.log(results.map((r) => r.length));

Output:

[ 120, 340, 88 ]

This is concurrency without parallelism: only one piece of JavaScript runs at any instant, but many I/O operations overlap. That model is what lets a single Node process serve large numbers of simultaneous connections cheaply.

Why blocking the event loop is harmful

Because there is only one thread for your JavaScript, any synchronous work that takes a long time — a big JSON.parse, a tight CPU loop, a *Sync file call — freezes everything. No other request is served, timers do not fire, and incoming connections pile up until the blocking call returns.

// Anti-pattern: this blocks the event loop for the whole duration.
function fibonacci(n) {
  return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(45)); // CPU-bound, blocks every other request

Warning: A single slow synchronous call stalls the entire process. Avoid *Sync APIs and heavy CPU work on the main thread in servers. Offload CPU-bound work to a Worker Thread or a separate service.

Synchronous vs asynchronous at a glance

AspectSynchronousAsynchronous
ExecutionOne operation at a time, in orderStarts work, continues, resumes on completion
Thread behaviorBlocks until doneFrees the thread during I/O
ConcurrencyNone — serialHigh, via the event loop
Typical APIsreadFileSync, JSON.parsereadFile, fetch, Promises, await
Best forStartup scripts, config loadingServers, network and disk I/O
RiskFreezes the process if slowAdds control-flow complexity

Choosing the right approach

Synchronous code is not evil — it is the right tool for one-off scripts, reading configuration at startup, or CLI tools that do one thing and exit. The blocking only matters when other work is waiting. In long-running servers, prefer asynchronous APIs everywhere a request could be delayed.

import { readFileSync } from "node:fs";

// Fine: runs once during startup, before the server accepts traffic.
const config = JSON.parse(readFileSync("./config.json", "utf8"));

// From here on, serve requests with non-blocking I/O only.

Best Practices

  • Default to asynchronous, Promise-based APIs (node:fs/promises, fetch) for anything I/O-bound in a server.
  • Reserve *Sync calls for startup, scripts, or CLIs that exit quickly — never in a request handler.
  • Use Promise.all (or other combinators) to run independent async operations concurrently instead of awaiting them one by one.
  • Move CPU-heavy work off the main thread with Worker Threads or a background service so the event loop stays responsive.
  • Prefer async/await over deeply nested callbacks for readable, linear-looking asynchronous code.
  • Measure event-loop lag in production so you can catch accidental blocking before users feel it.
Last updated June 14, 2026
Was this helpful?