Async in Node
Learn how Node.js handles asynchronous work — from old-school callbacks all the way to clean async/await — and how to keep your code from turning into a tangled mess.
Here is something that trips up almost every developer new to Node.js: JavaScript runs on a single thread. That means it can only do one thing at a time. Yet Node.js servers cheerfully handle thousands of simultaneous requests — file reads, database queries, HTTP calls — without breaking a sweat. How? The answer is asynchronous programming, and understanding it will unlock the full power of Node.js for you.
In this lesson we will travel through the history of async patterns in JavaScript: from humble callbacks, through Promises, and finally to the elegant async/await syntax used in all modern Node.js code. We will also cover error handling and running tasks in parallel. By the end, writing async code will feel natural.
#Why Async Exists: The Single-Thread Problem
Node.js is like a great waiter
Imagine a restaurant with one waiter. A bad waiter takes your order, walks to the kitchen, and stands there staring at the grill until your food is ready — blocking everyone else. A great waiter takes your order, drops it at the kitchen window, then immediately turns around and takes the next table's order. When the kitchen calls "order up!", the waiter comes back to deliver it.
Node.js is that great waiter. It kicks off slow work (reading a file, calling an API) and moves on immediately. When the slow work finishes, Node gets a notification and handles the result. This is the event loop in action.
If Node.js used the "bad waiter" model — pausing and waiting for every slow operation — a single slow database query would freeze the entire server for every user. Async patterns let Node.js hand slow work off to the operating system, stay responsive, and pick up results when they arrive.
#Era 1: Callbacks
The original async pattern in Node.js is the callback: a function you pass into another function, to be called when the work is done. Node's built-in APIs were all designed this way.
The convention is error-first callbacks — the first argument to your callback is always an error (or null if everything went fine).
const fs = require('fs');
fs.readFile('hello.txt', 'utf8', function (err, data) {
if (err) {
console.error('Something went wrong:', err.message);
return;
}
console.log('File contents:', data);
});
console.log('This line runs BEFORE the file is read!');Callback Hell
Callbacks work fine for one level deep. The problem comes when you need to chain several async operations — read a file, then parse its contents, then save a result. Each step nests inside the previous callback, creating deeply indented code nicknamed "callback hell" or "the pyramid of doom".
``js readFile('config.txt', (err, config) => { parseConfig(config, (err, settings) => { fetchUser(settings.userId, (err, user) => { saveUser(user, (err) => { // By now you've lost track of which err is which }); }); }); }); ``
This code is hard to read, hard to debug, and a maintenance nightmare. Promises were invented to solve exactly this.
#Era 2: Promises
A Promise is an object that represents a value that will be available at some point in the future — or an error if something goes wrong. Instead of passing a callback into a function, the function returns a Promise, and you attach handlers to it with .then() and .catch().
This transforms the pyramid into a flat, readable chain.
const fs = require('fs').promises;
fs.readFile('hello.txt', 'utf8')
.then((data) => {
console.log('File contents:', data);
return data.toUpperCase();
})
.then((upper) => {
console.log('Uppercased:', upper);
})
.catch((err) => {
console.error('Error:', err.message);
});Notice two things: the chain reads top-to-bottom like synchronous code, and a single .catch() at the end handles any error that occurs anywhere in the chain. That is already a huge improvement over callbacks.
You can also create your own Promises when wrapping older callback-based APIs:
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
wait(1000).then(() => console.log('1 second passed!'));#Era 3: async/await — Promises with a Superpower
async/await is not a replacement for Promises — it is syntax sugar on top of Promises. Under the hood, everything is still a Promise. But await lets you pause inside a function until a Promise settles, making async code look and feel synchronous.
Rules: - Mark the function with async. - Use await before any expression that returns a Promise. - An async function always returns a Promise itself.
const fs = require('fs').promises;
async function readAndPrint() {
const data = await fs.readFile('hello.txt', 'utf8');
const upper = data.toUpperCase();
console.log('Uppercased:', upper);
}
readAndPrint();#Error Handling with try/catch
With async/await, error handling uses the familiar try/catch blocks you already know from synchronous code. Wrap the awaited calls in a try block and handle failures in catch.
const fs = require('fs').promises;
async function readSafely(filename) {
try {
const data = await fs.readFile(filename, 'utf8');
console.log('Contents:', data);
} catch (err) {
console.error('Could not read file:', err.message);
} finally {
console.log('Done attempting to read.');
}
}
readSafely('missing.txt');Forgetting to await is a silent killer
If you forget await, the function returns a Promise object instead of its resolved value — and no error is thrown. Your code will silently produce wrong results.
```js // BAD: data is a Promise object, not a string const data = fs.readFile('hello.txt', 'utf8'); console.log(data); // [object Promise]
// GOOD const data = await fs.readFile('hello.txt', 'utf8'); ```
If you ever see [object Promise] in your output, this is almost always the cause.
#Running Tasks in Parallel with Promise.all
Sometimes you have several independent async tasks and you want to run them at the same time instead of one after another. Promise.all takes an array of Promises and returns a single Promise that resolves when all of them finish.
Compare the two approaches:
async function sequential() {
// Waits 1s, then waits another 1s — total: ~2 seconds
const a = await fetchUser(1); // 1 second
const b = await fetchUser(2); // 1 second
return [a, b];
}
async function parallel() {
// Both fire at the same time — total: ~1 second
const [a, b] = await Promise.all([
fetchUser(1),
fetchUser(2)
]);
return [a, b];
}One important caveat: if any Promise in the array rejects, Promise.all immediately rejects with that error. Wrap it in try/catch to handle failures gracefully.
For cases where you want all results even if some fail, check out Promise.allSettled — it waits for every Promise to either resolve or reject and gives you a status report for each one.
You `await` two async operations one after the other (sequentially) and each takes 500ms. You then refactor to use `Promise.all`. Roughly how long will the parallel version take?
Quick mental model for choosing a pattern
- Callbacks: only when using a legacy library that forces them (e.g. some older npm packages).
- Promises with .then(): handy for short chains or when you need fine-grained control.
- async/await: your default for all new code — clearest, easiest to debug.
- Promise.all: whenever two or more independent async tasks can safely run at the same time.
Key takeaways
- Node.js uses an event loop to handle many operations concurrently on a single thread — async patterns are what make this possible.
- Callbacks were the original async tool, but nesting them creates hard-to-read 'callback hell'.
- Promises flatten async chains and centralise error handling with a single .catch().
- async/await is syntactic sugar over Promises — it makes async code read like synchronous code and pairs naturally with try/catch.
- Promise.all runs independent tasks in parallel, dramatically cutting total wait time when tasks don't depend on each other.
The lesson shows that fs.readFile is asynchronous. What does this program print?
const fs = require('fs');
fs.readFile('hello.txt', 'utf8', function (err, data) {
console.log('Inside callback');
});
console.log('After readFile');This code has a bug — what's wrong?
const fs = require('fs').promises;
async function readAndPrint() {
const data = fs.readFile('hello.txt', 'utf8');
console.log('Contents:', data);
}
readAndPrint();Complete this async function so it pauses until the file is read and handles any error, matching the lesson's try/catch pattern.
async function readSafely(filename) { try { const data = fs.readFile(filename, 'utf8'); console.log('Contents:', data); } (err) { console.error('Could not read file:', err.message); } }
Arrange these lines into a working async function that fetches a user's name and score in parallel with Promise.all, as taught in the lesson.
async function loadUser() {console.log(name, score);
const [name, score] = await Promise.all([
}
]);
fetchScore()
fetchName(),
Write an async function called loadUserData that does three things in parallel using Promise.all: fetches a user name (simulated with a 500ms delay returning 'Alice'), fetches their score (300ms delay returning 42), and fetches their rank (400ms delay returning 'Gold'). Log all three results. Wrap everything in try/catch so errors are handled gracefully.
Try it yourself — a starting point to build on:
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function loadUserData() {
try {
// TODO: use Promise.all to fetch name, score, and rank in parallel
// delay(500, 'Alice') -> name
// delay(300, 42) -> score
// delay(400, 'Gold') -> rank
console.log(`Name: ${name}, Score: ${score}, Rank: ${rank}`);
} catch (err) {
console.error('Failed to load user data:', err.message);
}
}
loadUserData();