Skip to main content

Synchronous vs asynchronous code in Node.js

Synchronous code blocks the Node.js event loop until the operation finishes; asynchronous code registers the task with libuv and returns immediately, keeping the loop free for other work.

Theory

TL;DR

  • Think of a single cashier at a busy store: sync makes everyone wait while one customer checks out a full cart; async lets the cashier take a note and serve the next person right away
  • Main difference: sync halts the entire event loop; async keeps it free for incoming requests
  • Node.js runs on one thread, so a blocked loop means zero requests handled during that time
  • Rule: async for anything touching files, network, or databases; sync is fine for short in-memory operations in scripts

Quick example

js
const fs = require('fs'); // Sync: loop is frozen for ~100ms console.log('Start'); const data = fs.readFileSync('./file.txt', 'utf8'); // Everything stops here console.log(data); console.log('End'); // Output: Start -> file content -> End // Async: loop stays free console.log('Start'); fs.readFile('./file.txt', 'utf8', (err, data) => { if (err) return console.error(err); console.log(data); // Fires later, once the OS is done }); console.log('End'); // Output: Start -> End -> file content

The sync version freezes everything while Node waits on disk. The async version hands the job to libuv, returns instantly, and the callback runs once the file is ready.

Key difference

Node.js has one main thread. When sync I/O runs, that thread sits idle waiting for the disk or network, and nothing else gets processed. Async I/O delegates the wait to libuv's thread pool (4 threads by default), so the main thread keeps picking up new tasks from the event loop. The callback joins the task queue once the OS signals completion, and the loop runs it after the call stack empties.

When to use

  • CLI script with one file to read: sync is fine, nothing else is waiting
  • API server handling requests: async always; a 500ms sync read blocks every concurrent request
  • Parsing JSON already in memory: sync is fine, it is pure CPU work with no I/O wait
  • Database query or HTTP call: async, the network round trip takes 50-200ms
  • One-off debug or test script: sync makes the flow easier to follow

Async patterns compared

PatternError handlingReadabilityWhen to use
CallbacksFirst arg errNested, harder to followLegacy code, older Node APIs
Promises.catch()ChainableSequential async flows
async/awaittry/catchLinear, clearestMost production code today

All three patterns do the same thing under the hood. They are just different ways to write the callback that fires when libuv finishes the I/O work.

How Node handles async internally

V8 runs JavaScript on the main thread's call stack. When you call fs.readFile, Node passes the request to libuv, which hands it to the OS (or its own thread pool for blocking OS calls). Once the file is ready, libuv pushes the callback into the event loop's poll phase. After the call stack empties, the event loop picks it up and runs your code.

Promises and async/await use the microtask queue, which drains before the next event loop tick. That is why await reads like synchronous code even though it is not. The suspension and resume happen transparently.

Common mistakes

1. Sync I/O inside an HTTP handler

js
// Wrong: blocks every request for as long as the read takes app.get('/data', (req, res) => { const content = fs.readFileSync('./bigfile.json'); // Server frozen res.send(content); }); // Right: loop stays free app.get('/data', async (req, res) => { const content = await fs.promises.readFile('./bigfile.json'); res.send(content); });

A 500ms sync read under load means every request waits 500ms. I see this most often with config files loaded inside request handlers - harmless in a script, but it brings servers down under traffic. The fix is one line.

2. Sequential awaits in a loop

js
// Wrong: files read one by one, total time = sum of all reads for (const file of files) { const data = await fs.promises.readFile(file); // Serialized } // Right: all reads start at once const results = await Promise.all( files.map(f => fs.promises.readFile(f, 'utf8')) );

await inside a for loop serializes the operations. With 10 files at 100ms each, you wait 1000ms instead of roughly 100ms.

3. Forgetting the error argument in a callback

js
// Wrong: crashes on ENOENT, the 'data' param is actually the error object fs.readFile('./file.txt', (data) => console.log(data)); // Right fs.readFile('./file.txt', 'utf8', (err, data) => { if (err) return console.error(err); console.log(data); });

Node's callback convention is always (err, data). Skipping err means you silently treat the error object as file content and the process crashes on the next operation.

4. Callback hell

js
// Wrong: pyramid of doom, hard to handle errors fs.readFile(file1, (err1, data1) => { fs.readFile(file2, (err2, data2) => { fs.readFile(file3, (err3, data3) => { // ... }); }); }); // Right const [data1, data2, data3] = await Promise.all([ fs.promises.readFile(file1, 'utf8'), fs.promises.readFile(file2, 'utf8'), fs.promises.readFile(file3, 'utf8'), ]);

Real-world usage

  • Express.js: route handlers use async/await for DB queries (await User.findById(id)) and file reads
  • NestJS: guards and interceptors are async by default for auth checks
  • Fastify: plugins hook into the async lifecycle with fastify.get('/', async (req, reply) => {...})
  • Webpack (Node build tools): loaders compile JS and CSS asynchronously to avoid blocking the bundler
  • Any production server: fs.promises, axios, and most ORMs return Promises by default

Follow-up questions

Q: Why can single-threaded Node.js handle thousands of concurrent requests?
A: The event loop stays non-blocking because I/O waits are delegated to libuv's thread pool and the OS. JavaScript itself never blocks, so the loop keeps processing new events continuously.

Q: What is the difference between callbacks, Promises, and async/await?
A: Callbacks are the oldest pattern and nest badly under complex flows. Promises allow .then() chaining and better error propagation. Async/await is syntactic sugar over Promises that reads like synchronous code. All three compile to the same underlying mechanism.

Q: What if a task is CPU-intensive, not I/O?
A: The event loop still blocks, because CPU work runs on the main thread regardless of async syntax. The fix is the worker_threads module (available since Node 10) or the cluster module to spread load across multiple processes.

Q: What are process.nextTick and setImmediate, and how do they differ?
A: process.nextTick fires before the next event loop phase, ahead of any I/O callbacks. setImmediate fires in the check phase, after I/O. Overusing process.nextTick in a recursive pattern can starve I/O callbacks and delay file reads unexpectedly.

Q: How do you tune libuv's thread pool for heavy workloads?
A: Set the UV_THREADPOOL_SIZE environment variable before starting Node. The default is 4 threads; the maximum is 1024. Heavy crypto or database-heavy services often benefit from a larger pool, e.g. UV_THREADPOOL_SIZE=16 node server.js.

Examples

Sync vs async: output order side by side

js
const fs = require('fs'); const fsPromises = require('fs').promises; // --- Sync --- console.log('A'); const raw = fs.readFileSync('./file.txt', 'utf8'); console.log('B', raw.slice(0, 10)); console.log('C'); // Output: A -> B (content) -> C // The loop was blocked between A and B. // --- Async with async/await --- async function run() { console.log('A'); const content = await fsPromises.readFile('./file.txt', 'utf8'); console.log('B', content.slice(0, 10)); console.log('C'); } run(); // Output inside run(): A -> B (content) -> C // But other code queued on the loop can run between A and B.

The order inside the async function looks identical, but the event loop was free to handle other work during the await. That is the entire point.

Express route handler: async file read

js
const express = require('express'); const fs = require('fs').promises; const app = express(); // While this reads the file, the server accepts other requests app.get('/profile/:user', async (req, res) => { try { const raw = await fs.readFile( `./profiles/${req.params.user}.json`, 'utf8' ); res.json(JSON.parse(raw)); } catch (err) { res.status(404).json({ error: 'Profile not found' }); } }); app.listen(3000);

Two requests arrive at the same time. Both reach await fs.readFile. Both yield to the event loop while the OS reads the files. Neither blocks the other, and both respond once their file is ready.

Parallel config loading with Promise.all

js
const fs = require('fs').promises; async function loadConfig() { // Sequential: ~300ms total for 3 files at ~100ms each // const db = await fs.readFile('./db.json', 'utf8'); // const cache = await fs.readFile('./cache.json', 'utf8'); // const app = await fs.readFile('./app.json', 'utf8'); // Parallel: ~100ms total, all three reads start at once const [db, cache, app] = await Promise.all([ fs.readFile('./db.json', 'utf8'), fs.readFile('./cache.json', 'utf8'), fs.readFile('./app.json', 'utf8'), ]); return { db: JSON.parse(db), cache: JSON.parse(cache), app: JSON.parse(app), }; }

Promise.all starts all three reads at once and waits for the slowest one. The total time is roughly equal to the single longest read rather than the sum of all three.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?