1. Introduction ⏳
Asynchronous programming is the heart of Node.js. Because the runtime is single-threaded, it relies on non-blocking patterns — callbacks, promises, and async/await — to handle I/O and timers without freezing execution. This tutorial covers every major async pattern and how they interact with the event loop.
2. Synchronous vs Asynchronous Programming 🔄
Synchronous code executes line by line, each statement waiting for the previous one to finish. Asynchronous code allows an operation to be scheduled and continue later, letting the program do other work in the meantime.
| Aspect | Synchronous | Asynchronous |
|---|---|---|
| Execution | Blocking, sequential | Non-blocking, event-driven |
| Example | fs.readFileSync() | fs.readFile() |
| Scalability | Poor under concurrency | Excellent under concurrency |
3. Callbacks 📞
3.1 Callback Patterns
A callback is a function passed as an argument to be invoked later, once an operation completes. Node's convention is error-first callbacks: the first parameter is always error (or null), followed by the result.
error-first-callback.js
const fs = require('node:fs');
fs.readFile('./data.txt', 'utf-8', (err, data) => {
if (err) {
console.error('Failed to read file:', err.message);
return;
}
console.log(data);
});3.2 Callback Hell
Callback hell (or the "pyramid of doom") happens when multiple asynchronous operations are nested inside each other's callbacks, producing deeply indented, hard-to-follow code.
callback-hell.js
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
console.log(details); // deeply nested!
});
});
});Warning
4. Promises 🤝
A Promise represents the eventual result (or failure) of an asynchronous operation. It exists in one of three states: pending, fulfilled, or rejected.
promise-basics.js
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
delay(1000).then(() => console.log('1 second passed'));4.1 Promise Chaining
Each .then() returns a new Promise, allowing operations to be chained sequentially rather than nested.
promise-chaining.js
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetails(orders[0].id))
.then((details) => console.log(details))
.catch((err) => console.error('Something failed:', err.message));4.2 Promise Error Handling
A single .catch() at the end of a chain catches any rejection from earlier in the chain, since errors propagate through .then() calls until handled.
promise-error-handling.js
fetchData()
.then(process)
.catch((err) => console.error('Caught:', err.message))
.finally(() => console.log('Cleanup runs regardless'));Important
5. async/await ✨
5.1 async Functions
An async function always returns a Promise, even if you return a plain value — it gets automatically wrapped.
async-function.js
async function getGreeting() {
return 'Hello!'; // implicitly wrapped as Promise.resolve('Hello!')
}5.2 await
await pauses execution within an async function until the awaited Promise settles, unwrapping its resolved value or throwing its rejection as a regular Error.
await-example.js
async function loadUser(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
return orders;
} catch (err) {
console.error('Failed to load user data:', err.message);
throw err;
}
}Tip
6. Promise Combinators 🎯
6.1 Promise.all
Runs promises concurrently and resolves when all succeed — or rejects immediately if any fails.
promise-all.js
const [user, posts, comments] = await Promise.all([
getUser(id),
getPosts(id),
getComments(id),
]);6.2 Promise.allSettled
Waits for all promises to settle regardless of outcome, returning an array of { status, value | reason } objects — useful when partial failures are acceptable.
promise-allsettled.js
const results = await Promise.allSettled([
fetchFromAPI1(),
fetchFromAPI2(),
]);
results.forEach((r) => {
if (r.status === 'fulfilled') console.log(r.value);
else console.error(r.reason);
});6.3 Promise.race
Resolves or rejects as soon as the first promise settles — commonly used to implement timeouts.
promise-race.js
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out')), ms)
);
return Promise.race([promise, timeout]);
}6.4 Promise.any
Resolves as soon as the first promise fulfills, ignoring rejections unless all reject (throwing an AggregateError).
promise-any.js
const fastestMirror = await Promise.any([
fetch('https://mirror1.example.com/data'),
fetch('https://mirror2.example.com/data'),
]);7. Timers ⏰
7.1 setTimeout
settimeout.js
const timer = setTimeout(() => console.log('Fired after 1s'), 1000);
clearTimeout(timer); // cancel before it fires7.2 setInterval
setinterval.js
const interval = setInterval(() => console.log('tick'), 1000);
setTimeout(() => clearInterval(interval), 5000); // stop after 5s7.3 setImmediate
setImmediate() schedules a callback to run in the check phase of the event loop, immediately after the current poll phase completes.
setimmediate.js
setImmediate(() => console.log('Runs after I/O events in this cycle'));7.4 process.nextTick
process.nextTick() queues a callback to run before the event loop continues to any phase — even before Promise microtasks. Overuse can starve the event loop of I/O.
nexttick.js
process.nextTick(() => console.log('Runs before microtasks and timers'));Danger
7.5 queueMicrotask
queueMicrotask() schedules a callback on the same microtask queue used by Promise callbacks — it runs after process.nextTick callbacks but before macrotasks.
queuemicrotask.js
queueMicrotask(() => console.log('Runs as a microtask'));8. Event Loop Integration 🔁
Understanding the ordering of these mechanisms is essential for predicting execution order in complex async code.
Example
9. Async Iterators 🔂
Async iterators let you use for await...of to consume values that arrive asynchronously — commonly used with streams.
async-iterator.js
const fs = require('node:fs');
async function readLines(filePath) {
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
for await (const chunk of stream) {
console.log('Chunk:', chunk);
}
}10. AbortController 🛑
AbortController provides a standard way to cancel asynchronous operations — like fetch requests or long-running tasks — via a shared AbortSignal.
abort-controller.js
const controller = new AbortController();
const { signal } = controller;
setTimeout(() => controller.abort(), 3000); // cancel after 3s
try {
const response = await fetch('https://api.example.com/data', { signal });
console.log(await response.json());
} catch (err) {
if (err.name === 'AbortError') console.log('Request was aborted');
}11. Retry Strategies 🔄
Transient failures (network blips, rate limits) can often be handled with a retry strategy, typically combined with exponential backoff to avoid overwhelming a struggling service.
retry-with-backoff.js
async function retry(fn, attempts = 3, delayMs = 500) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, delayMs * 2 ** i));
}
}
}12. Concurrency Patterns 🧵
Running too many async operations at once can overwhelm downstream services or exhaust the thread pool. A common pattern is limiting concurrency using a semaphore-like queue.
concurrency-limit.js
async function mapWithConcurrency(items, limit, fn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const current = index++;
results[current] = await fn(items[current]);
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}13. Performance Optimization ⚡
- Prefer Promise.all over sequential await calls when operations are independent.
- Avoid excessive process.nextTick recursion, which can starve the event loop.
- Limit concurrency when calling rate-limited external APIs.
- Use AbortController to cancel work that's no longer needed, freeing resources sooner.
14. Best Practices ✅
- Always attach a .catch() or wrap await in try/catch — never leave a Promise rejection unhandled.
- Use Promise.all for independent operations instead of awaiting them one by one.
- Reach for async/await over raw .then() chains for readability in most application code.
- Use AbortController for any operation with a deadline or cancellation requirement.
15. Common Mistakes ⚠️
- Awaiting independent async calls sequentially instead of using Promise.all, hurting performance.
- Forgetting await inside an async function, causing a Promise to be used where a resolved value was expected.
- Mixing callbacks and Promises inconsistently within the same codebase.
- Not handling the 'unhandledRejection' process event during development.
- Using Promise.all when partial failures should be tolerated — Promise.allSettled is usually more appropriate there.