Asynchronous code is the backbone of every Node.js application. Because Node.js never blocks waiting for I/O, all operations that involve reading data — database queries, file reads, HTTP calls — are asynchronous. Over the years JavaScript developed three patterns for handling async results: callbacks, Promises, and async/await. Modern MERN development uses async/await almost exclusively, but you need to understand all three to read existing code, debug async errors, and use APIs that still use callbacks or Promises directly. This lesson will make all three click into place.
The Three Async Patterns — At a Glance
| Pattern | Introduced | Style | Used In MERN |
|---|---|---|---|
| Callbacks | ES5 / Node.js origins | Function passed as last argument | Legacy code, some Node.js core APIs |
| Promises | ES6 (2015) | .then().catch() chains |
Foundation of async/await, Promise.all() |
| async/await | ES8 (2017) | await inside async function |
Standard in all modern Express route handlers |
async/await is not a replacement for Promises — it is syntactic sugar built on top of them. An async function always returns a Promise. await pauses execution inside the function until a Promise resolves, then gives you the resolved value directly. Under the hood it is still Promises and the event loop — async/await just makes the code look synchronous without actually blocking.Promise.all([op1, op2, op3]). It starts all operations simultaneously and resolves when all of them finish. If any one rejects, the entire Promise.all rejects. For cases where you want all results even if some fail, use Promise.allSettled().async/await inside Array.forEach(). forEach does not wait for async callbacks — it fires them all and moves on immediately, and you have no way to await the overall result. Use for...of for sequential async iteration, or Promise.all(array.map(async item => ...)) for parallel async iteration.Pattern 1 — Callbacks
// Callbacks: the async result is delivered via a function you pass in.
// Error-first convention: callback(error, result)
const fs = require('fs');
fs.readFile('./config.json', 'utf8', function(err, data) {
if (err) {
console.error('Read failed:', err.message);
return; // must return to stop execution
}
const config = JSON.parse(data);
console.log(config);
// All code that depends on the file must live INSIDE this callback
// → leads to deeply nested "callback hell" with multiple async operations
});
console.log('This runs BEFORE the file is read'); // async!
// Callback hell — the problem that Promises were invented to solve
getUserById(userId, function(err, user) {
if (err) return handleError(err);
getPostsByUser(user._id, function(err, posts) {
if (err) return handleError(err);
getCommentsForPosts(posts, function(err, comments) {
if (err) return handleError(err);
// 3 levels deep — and we have not even handled the response yet
});
});
});
Pattern 2 — Promises
// Promises: represent a value that will be available in the future.
// States: pending → fulfilled (resolved) or rejected
const fs = require('fs/promises');
// .then() receives the resolved value, .catch() receives any error
fs.readFile('./config.json', 'utf8')
.then(data => {
const config = JSON.parse(data);
console.log(config);
return config; // return a value to pass it to the next .then()
})
.then(config => {
// do something with config
})
.catch(err => {
console.error('Failed:', err.message); // catches errors from ALL steps above
})
.finally(() => {
console.log('Always runs — success or failure');
});
// Chained Promises — much cleaner than callback hell:
getUser(userId)
.then(user => getPostsByUser(user._id))
.then(posts => getCommentsForPosts(posts))
.then(comments => res.json(comments))
.catch(err => next(err));
Pattern 3 — async/await (Modern Standard)
// async/await: write async code that looks synchronous
// await can only be used inside an async function
const fs = require('fs/promises');
async function loadConfig() {
try {
const data = await fs.readFile('./config.json', 'utf8'); // waits, non-blocking
const config = JSON.parse(data);
return config;
} catch (err) {
console.error('Failed to load config:', err.message);
throw err; // re-throw so the caller can handle it
}
}
// In an Express route handler:
app.get('/api/posts', async (req, res, next) => {
try {
const posts = await Post.find().sort({ createdAt: -1 });
res.json({ success: true, data: posts });
} catch (err) {
next(err); // pass to global error middleware
}
});
Promise.all — Parallel Execution
// Sequential — slow (waits for each one before starting the next)
const user = await User.findById(userId); // 40ms
const posts = await Post.find({ author: userId }); // then 40ms
const comments = await Comment.find({ user: userId }); // then 40ms
// Total: ~120ms
// Parallel with Promise.all — fast (all three run simultaneously)
const [user, posts, comments] = await Promise.all([
User.findById(userId),
Post.find({ author: userId }),
Comment.find({ user: userId }),
]);
// Total: ~40ms (limited by the slowest query, not the sum)
Error Handling in Each Pattern
| Pattern | Error Handling |
|---|---|
| Callback | Check if (err) as first line of every callback |
| Promise | .catch(err => ...) at the end of the chain |
| async/await | try { await ... } catch (err) { ... } |
| Express async routes | Pass caught error to next(err) for global error handler |
Common Mistakes
Mistake 1 — await inside forEach
❌ Wrong — forEach does not await async callbacks:
const ids = ['a', 'b', 'c'];
ids.forEach(async (id) => {
await Post.findByIdAndDelete(id); // fires all three, none are awaited
});
// Code after forEach continues immediately — deletions may not be done yet
✅ Correct — use for…of for sequential or Promise.all for parallel:
// Sequential
for (const id of ids) { await Post.findByIdAndDelete(id); }
// Parallel
await Promise.all(ids.map(id => Post.findByIdAndDelete(id)));
Mistake 2 — Forgetting to await
❌ Wrong — omitting await returns a pending Promise, not the resolved value:
const post = Post.findById(id); // ← missing await — post is a Promise, not a document
console.log(post.title); // undefined — Promise has no .title property
✅ Correct:
const post = await Post.findById(id); // resolved document ✓
console.log(post.title); // "My First Post"
Mistake 3 — Swallowing errors silently in catch blocks
❌ Wrong — catching an error and doing nothing hides bugs:
try {
await User.create(userData);
} catch (err) {
// silent — bug is hidden, user gets no feedback, nothing is logged
}
✅ Correct — always log, respond, or re-throw:
try {
await User.create(userData);
} catch (err) {
console.error('User creation failed:', err.message);
next(err); // or: res.status(400).json({ message: err.message })
}
Quick Reference
| Task | Code |
|---|---|
| Async route handler | app.get('/path', async (req, res, next) => { ... }) |
| Await a query | const data = await Model.find() |
| Handle async error | try { await ... } catch(err) { next(err) } |
| Run in parallel | const [a, b] = await Promise.all([op1, op2]) |
| All settle (no throws) | await Promise.allSettled([op1, op2]) |
| Async array iteration | for (const item of arr) { await process(item) } |