Last 30 Days
No notifications
Node.js is single-threaded but handles thousands of concurrent operations through asynchronous programming.
| Pattern | Era | Readability |
| Callbacks | 2009+ | ⭐ (callback hell) |
| Promises | 2015 (ES6) | ⭐⭐⭐ |
| async/await | 2017 (ES8) | ⭐⭐⭐⭐⭐ |
getUser(id, (err, user) => {
getPosts(user.id, (err, posts) => {
getComments(posts[0].id, (err, comments) => {
// 3 levels deep... and growing
});
});
});getUser(id)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => console.log(comments))
.catch(err => console.error(err));async function loadData(id) {
try {
const user = await getUser(id);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
console.log(comments);
} catch (err) {
console.error(err);
}
}A backend spends most of its time waiting — for the database, for an API, for a file. Node has a single thread, so if it blocked while waiting, your whole server would freeze.
The rule: never block, always defer. Node hands the wait off to the OS / a thread pool, runs other requests, and resumes you when the answer arrives. The mechanism that schedules all this is the event loop.
// 1. Callbacks (the original — "callback hell")
fs.readFile('a.json', (err, data) => {
if (err) return console.error(err);
fs.writeFile('b.json', data, (err) => {
if (err) return console.error(err);
console.log('done');
});
});// 2. Promises
fs.promises.readFile('a.json')
.then((data) => fs.promises.writeFile('b.json', data))
.then(() => console.log('done'))
.catch(console.error);
// 3. async / await — what you should write today
try {
const data = await fs.promises.readFile('a.json');
await fs.promises.writeFile('b.json', data);
console.log('done');
} catch (err) {
console.error(err);
}
All three describe the *same* operation. async/await is just nicer syntax over Promises.
A Promise is an object that will eventually be fulfilled with a value or rejected with an error.
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}await wait(1000); // pauses this function for 1 s — without blocking the event loop
async / awaitasync function getUser(id) {
const res = await fetch(/api/users/${id});
if (!res.ok) throw new Error('not found');
return res.json();
}async makes a function return a Promise automatically.await pauses *this* function until the Promise resolves; everything else keeps running.async function rejects the returned Promise — handle it with try/catch.1. Forgetting await. const u = getUser(1) gives you a Promise, not a user. u.name is undefined.
2. await in a loop when calls are independent. for + await runs them one after another. Use Promise.all for parallelism.
3. No try/catch. A rejected Promise becomes an *unhandled rejection* and may crash newer Node versions.
4. async for no reason. If you don't use await, the function doesn't need to be async.
5. Mixing .then and await in the same flow. Pick a style per function.
6. CPU-heavy work inside an async fn. await doesn't make synchronous CPU loops non-blocking. Use Worker Threads.
// Sequential (slow when calls are independent)
const u = await fetchUsers(); // 200 ms
const p = await fetchPosts(); // 300 ms
const c = await fetchComments(); // 150 ms
// total ≈ 650 ms// Parallel — start all, wait for all
const [u, p, c] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
// total ≈ 300 ms (the slowest one)
Use sequential only when later calls *depend* on earlier results.
// All must succeed; first rejection wins
await Promise.all([a, b, c]);// Wait for all results — never throws
const results = await Promise.allSettled([a, b, c]);
// each result: { status: 'fulfilled', value } | { status: 'rejected', reason }
// First to *resolve* wins (others ignored unless all reject)
await Promise.any([a, b, c]);
// First to settle (resolve or reject) — useful for timeouts
await Promise.race([fetch('/slow'), wait(2000).then(() => { throw new Error('timeout'); })]);
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000); // cancel after 5 sconst res = await fetch('/api/big', { signal: ac.signal });
Almost every modern Node API (fetch, fs.readFile, timers, streams) accepts an AbortSignal. Essential for cancelling stale requests in long-running services.
async function handler(req, res, next) {
try {
const data = await db.users.findById(req.params.id);
if (!data) return res.status(404).json({ error: 'not found' });
res.json(data);
} catch (err) {
next(err); // hand off to a central error middleware
}
}Never console.log and swallow — propagate the error so a single place decides the HTTP response.
When all synchronous code finishes, Node loops through phases:
┌───────────────┐
│ timers │ setTimeout / setInterval callbacks
├───────────────┤
│ pending I/O │ some system callbacks
├───────────────┤
│ poll │ most I/O callbacks (fs reads, sockets…)
├───────────────┤
│ check │ setImmediate callbacks
├───────────────┤
│ close │ socket close events
└───────────────┘
↑
(between every phase, the microtask queue drains:
Promise .then, queueMicrotask, process.nextTick)Microtasks (Promises) run before the next macrotask. That's why this prints 1, 4, 3, 2:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');process.nextTick vs queueMicrotask vs setImmediateprocess.nextTick(cb) — runs before any other microtask. Highest priority, easy to starve the loop.queueMicrotask(cb) — same queue as Promise callbacks.setImmediate(cb) — runs in the *next* loop iteration's check phase.setTimeout(cb, 0) — at least one full loop iteration later, in the timers phase.import { createReadStream } from 'node:fs';// for-await-of works on any async iterable (streams, paginated APIs…)
for await (const chunk of createReadStream('big.txt', 'utf8')) {
process.stdout.write(chunk);
}
// Build your own paginator
async function* pages(url) {
let next = url;
while (next) {
const res = await fetch(next).then(r => r.json());
yield res.items;
next = res.nextUrl;
}
}
for await (const items of pages('/api/users?page=1')) {
console.log(items.length);
}
Promise.all runs everything at once — fine for 10 things, terrible for 10 000 (you'll exhaust file handles or rate-limit the API). Cap concurrency:
import pLimit from 'p-limit';
const limit = pLimit(5); // at most 5 in flightconst results = await Promise.all(
urls.map((u) => limit(() => fetch(u)))
);
process.on('unhandledRejection', (reason) => {
console.error('UNHANDLED', reason);
// log + maybe gracefully exit
});process.on('uncaughtException', (err) => {
console.error('FATAL', err);
process.exit(1);
});
In newer Node, unhandled rejections crash the process by default — these handlers exist so you log first.
1. Convert a callback-style fs.readFile snippet to fs/promises + async/await.
2. Fetch three independent URLs sequentially and time it; switch to Promise.all and compare.
3. Use AbortController to cancel a fetch that runs longer than 2 s.
4. Predict the output of: console.log(1); setTimeout(() => console.log(2)); Promise.resolve().then(() => console.log(3)); console.log(4); — then run it.