Callbacks, Promises and async/await in Node.js

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
Note: 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.
Tip: When you need multiple independent async operations to run at the same time, use 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().
Warning: A common mistake is using 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) }

🧠 Test Yourself

An Express route needs to fetch a post and its author from MongoDB at the same time (they are independent). Which code is both correct and most efficient?