Topic 4: Working with Asynchronous Programming
📖 6 min read · 🎯 intermediate · 🧭 Prerequisites: working-with-package-lock-json-to-lock-the-node-modules-versions, using-rest
Why this matters
Here's the thing — have you ever clicked a button in an app and the whole screen just froze? Nothing you could do, just waiting. That's what happens when your Node.js code reads a file, calls an API, or hits a database synchronously — it stops everything until the job is done. In a real server handling hundreds of users, that's a disaster. Asynchronous programming is how we fix it. With callbacks, promises, and async/await, your code can kick off a task and keep working while it waits — no freezing, no blocking, just smooth execution.
What You'll Learn
- Understand how Node.js's non-blocking I/O model and event loop enable asynchronous execution
- Handle async operations the traditional way using callbacks
- Upgrade to promises and the
.then/.catchchain pattern - Write cleaner async code using
async/awaitsyntax - Run multiple async operations in parallel with
Promise.all - Handle errors correctly in all three async styles
- Schedule deferred and repeating tasks with
setTimeoutandsetInterval
The Analogy
Think of a busy coffee shop. A synchronous barista would take one order, make the drink start to finish, hand it over, then — and only then — take the next order. The whole queue waits. An asynchronous barista takes your order, starts the espresso machine, and immediately turns to the next customer while the machine does its work. When the machine beeps (the callback fires), the barista finishes your drink. Node.js is that second barista: it kicks off slow operations (disk reads, network calls) and keeps processing other work until the result is ready.
Chapter 1: Understanding Asynchronous Programming
Asynchronous programming allows tasks to run concurrently, improving performance and responsiveness. Node.js, with its non-blocking I/O model, heavily relies on asynchronous programming to handle multiple operations efficiently.
Key Concepts
- Non-blocking I/O — Allows other operations to continue before the previous one finishes. When Node asks the OS to read a file, it registers a callback and moves on; it does not sit idle.
- Event Loop — The heart of Node's async model. It continuously monitors a queue of completed operations and executes their registered callbacks when tasks are finished, keeping the single-threaded runtime from stalling.
sequenceDiagram
participant App
participant EventLoop
participant OS
App->>EventLoop: fs.readFile('example.txt', cb)
EventLoop->>OS: Delegate file read
App->>EventLoop: Continue executing next lines
OS-->>EventLoop: File read complete
EventLoop->>App: Execute cb(null, data)
Chapter 2: Callbacks
Callbacks are the traditional way to handle asynchronous operations in Node.js — you pass a function as the last argument, and Node calls it when the operation completes.
Example: Reading a File with a Callback
readFileCallback.js:
const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return;
}
console.log('File content:', data);
});
Running the application:
node readFileCallback.js
The callback receives two arguments by Node convention: err first, then the result. Always check err before using data — if you ignore it, a missing file will silently crash your logic downstream.
Chapter 3: Promises
Promises provide a more elegant way to handle asynchronous operations compared to callbacks. A Promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Node's fs.promises API returns promises directly.
Example: Reading a File with Promises
readFilePromise.js:
const fs = require('fs').promises;
fs.readFile('example.txt', 'utf8')
.then((data) => {
console.log('File content:', data);
})
.catch((err) => {
console.error('Error reading file:', err);
});
Running the application:
node readFilePromise.js
The .then() handler runs on success; .catch() handles any rejection. Chaining multiple .then() calls avoids the deeply nested "callback hell" that plagues raw callback code.
Chapter 4: Async/Await
async/await is syntactic sugar built on top of promises. It lets you write asynchronous code that reads like synchronous code, making intent clearer and error paths easier to reason about.
Example: Reading a File with Async/Await
readFileAsyncAwait.js:
const fs = require('fs').promises;
async function readFile() {
try {
const data = await fs.readFile('example.txt', 'utf8');
console.log('File content:', data);
} catch (err) {
console.error('Error reading file:', err);
}
}
readFile();
Running the application:
node readFileAsyncAwait.js
await pauses execution inside the async function until the promise resolves — but it does not block the event loop. Other callbacks and timers still run while this function is suspended.
Chapter 5: Handling Multiple Asynchronous Operations
When you need results from several independent async operations, running them sequentially wastes time. Promise.all fires them all at once and waits for every promise to settle.
Example: Reading Multiple Files with Promise.all
readMultipleFiles.js:
const fs = require('fs').promises;
Promise.all([
fs.readFile('example1.txt', 'utf8'),
fs.readFile('example2.txt', 'utf8')
])
.then((results) => {
console.log('File 1 content:', results[0]);
console.log('File 2 content:', results[1]);
})
.catch((err) => {
console.error('Error reading files:', err);
});
Running the application:
node readMultipleFiles.js
Example: Reading Multiple Files with Async/Await
readMultipleFilesAsyncAwait.js:
const fs = require('fs').promises;
async function readFiles() {
try {
const [data1, data2] = await Promise.all([
fs.readFile('example1.txt', 'utf8'),
fs.readFile('example2.txt', 'utf8')
]);
console.log('File 1 content:', data1);
console.log('File 2 content:', data2);
} catch (err) {
console.error('Error reading files:', err);
}
}
readFiles();
Running the application:
node readMultipleFilesAsyncAwait.js
Promise.all rejects immediately if any promise in the array rejects. If you need partial results even when some fail, use Promise.allSettled instead.
Chapter 6: Handling Errors in Asynchronous Code
Proper error handling is essential — an unhandled rejection in async code can crash a Node process silently in older versions or emit an UnhandledPromiseRejection warning in newer ones.
Example: Error Handling with Async/Await
errorHandlingAsyncAwait.js:
const fs = require('fs').promises;
async function readFile() {
try {
const data = await fs.readFile('nonexistent.txt', 'utf8');
console.log('File content:', data);
} catch (err) {
console.error('Error reading file:', err.message);
}
}
readFile();
Running the application:
node errorHandlingAsyncAwait.js
Output:
Error reading file: ENOENT: no such file or directory, open 'nonexistent.txt'
The try/catch block around await expressions catches both synchronous throws and rejected promises, giving you one unified error-handling path.
Chapter 7: Using setTimeout and setInterval
Node exposes setTimeout and setInterval for scheduling tasks. Both are non-blocking — registering a timer does not pause execution; the callback fires later via the event loop.
Example: Using setTimeout
setTimeoutExample.js:
console.log('Before timeout');
setTimeout(() => {
console.log('Executed after 2 seconds');
}, 2000);
console.log('After timeout');
Running the application:
node setTimeoutExample.js
Output:
Before timeout
After timeout
Executed after 2 seconds
Notice that 'After timeout' prints immediately — setTimeout registers the callback and returns; it does not block the next line.
Example: Using setInterval
setIntervalExample.js:
let counter = 0;
const intervalId = setInterval(() => {
counter += 1;
console.log(`Counter: ${counter}`);
if (counter === 5) {
clearInterval(intervalId);
console.log('Interval cleared');
}
}, 1000);
Running the application:
node setIntervalExample.js
Output:
Counter: 1
Counter: 2
Counter: 3
Counter: 4
Counter: 5
Interval cleared
Always store the return value of setInterval so you can call clearInterval when you're done. A forgotten interval keeps the Node process alive indefinitely.
🧪 Try It Yourself
Task: Write a script that reads two files concurrently and writes a combined summary to a third file.
- Create
file1.txtcontainingHelloandfile2.txtcontainingWorld. - Use
Promise.all+async/awaitto read both files simultaneously. - Write the combined content (
Hello World) tocombined.txtusingfs.promises.writeFile.
Success criterion: Running the script produces combined.txt with the text Hello World and logs Done! combined.txt written. to the console.
Starter snippet:
const fs = require('fs').promises;
async function combineFiles() {
try {
const [data1, data2] = await Promise.all([
fs.readFile('file1.txt', 'utf8'),
fs.readFile('file2.txt', 'utf8')
]);
const combined = `${data1.trim()} ${data2.trim()}`;
await fs.writeFile('combined.txt', combined, 'utf8');
console.log('Done! combined.txt written.');
} catch (err) {
console.error('Something went wrong:', err.message);
}
}
combineFiles();
🔍 Checkpoint Quiz
Q1. What is the role of the Event Loop in Node.js asynchronous programming?
A) It runs all code on multiple CPU threads simultaneously
B) It monitors completed async operations and executes their callbacks
C) It pauses execution until each I/O operation finishes
D) It converts callback-based APIs into promises automatically
Q2. Given the following code, what is the order of output?
console.log('A');
setTimeout(() => console.log('B'), 0);
console.log('C');
A) A, B, C
B) B, A, C
C) A, C, B
D) C, A, B
Q3. What happens if one of the promises passed to Promise.all rejects?
A) The remaining promises are cancelled and the array returns partial results
B) The rejection is silently ignored and other results are returned
C) Promise.all immediately rejects with that error, even if other promises succeed
D) Promise.all waits for all promises to finish before rejecting
Q4. Rewrite this callback-based code using async/await:
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) return console.error(err);
console.log(data);
});
How would you handle the error in the async/await version?
A1. B — The event loop continuously checks the queue for completed async operations and invokes their registered callbacks; it is the mechanism that makes single-threaded Node.js non-blocking.
A2. C — 'A' and 'C' execute synchronously in order. Even with a 0ms delay, setTimeout registers a callback that runs after the current synchronous call stack is clear, so 'B' prints last.
A3. C — Promise.all fails fast: the moment any single promise rejects, the whole Promise.all rejects with that reason. Use Promise.allSettled if you need results from the promises that did succeed.
A4. Wrap the await call in a try/catch block:
const fs = require('fs').promises;
async function readData() {
try {
const data = await fs.readFile('data.txt', 'utf8');
console.log(data);
} catch (err) {
console.error(err);
}
}
readData();
The catch block catches both OS-level errors (like ENOENT) and any unexpected thrown values, mirroring the if (err) check in the callback version.
🪞 Recap
- Node.js uses a non-blocking I/O model and an event loop to handle async operations without stalling the process.
- Callbacks are the foundational pattern but nest poorly; promises introduced chainable
.then/.catchto flatten async flows. async/awaitis syntactic sugar over promises that lets async code read like sequential synchronous code.Promise.allruns multiple independent promises in parallel and resolves when all succeed (or rejects on the first failure).- Always handle errors in async code — via callback's
errparameter,.catch(), ortry/catcharoundawait. setTimeoutandsetIntervalschedule deferred and repeating tasks through the event loop;clearIntervalis required to stop a running interval.
📚 Further Reading
- Node.js fs.promises API docs — the source of truth for all promise-based file system methods
- MDN: Using Promises — in-depth guide to the promise model shared by browsers and Node
- Node.js Event Loop explainer — official deep-dive into how the event loop processes timers, I/O, and callbacks
- ⬅️ Previous: Using REST
- ➡️ Next: Building a HTTP Server with Node.js using HTTP APIs