Callbacks, Promises, and Async/Await in Node.js

Callbacks, Promises, and Async/Await in Node.js
Node.js is built on asynchronous I/O. To work with it effectively you need to understand the three async patterns that have evolved over its history: callbacks, Promises, and async/await. Each one builds on the last. Modern Node.js code uses async/await almost exclusively — but understanding callbacks and Promises is essential because you will encounter them in older code, in npm packages, and in job interviews.
This is Module 6 of the Node.js Full‑Stack Developer course. If you have not read Module 5 on the Event Loop, do that first — it explains why async patterns exist.
Part 1: Callbacks
A callback is simply a function you pass as an argument to another function, to be called later when an operation completes. It is the original async pattern in Node.js.
The Error-First Convention
Node.js standardised a specific callback signature: the error-first callback (also called a Node-style callback):
function myCallback(err, result) {
if (err) {
// Handle the error
console.error('Something went wrong:', err.message)
return
}
// Use the result
console.log('Success:', result)
}The rules are:
- First argument is always
err—nullif successful, anErrorobject if not - Subsequent arguments carry the result data
- Always check
errbefore using the result
Reading a File with Callbacks
const fs = require('fs')
fs.readFile('./data.txt', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read file:', err.message)
return
}
console.log('File contents:', data)
})
console.log('This runs BEFORE the file is read')Output:
This runs BEFORE the file is read
File contents: Hello from data.txtThe file read is asynchronous — readFile returns immediately, and the callback fires later when the OS delivers the file contents.
Writing Your Own Callback-Based Function
function fetchUser(id, callback) {
// Simulate a database call with setTimeout
setTimeout(() => {
if (id <= 0) {
callback(new Error('Invalid user ID'))
return
}
const user = { id, name: 'Alice', email: 'alice@example.com' }
callback(null, user)
}, 100)
}
fetchUser(1, (err, user) => {
if (err) {
console.error(err.message)
return
}
console.log(user) // { id: 1, name: 'Alice', email: 'alice@example.com' }
})
fetchUser(-1, (err, user) => {
if (err) {
console.error(err.message) // Invalid user ID
return
}
})Callback Hell — The Pyramid of Doom
Callbacks work fine for a single async operation. But when you need to chain several operations in sequence — where each depends on the result of the last — callbacks nest deeply:
// ❌ Callback hell — hard to read, hard to maintain
fs.readFile('config.json', 'utf8', (err, config) => {
if (err) return handleError(err)
parseConfig(config, (err, parsed) => {
if (err) return handleError(err)
connectToDatabase(parsed.db, (err, db) => {
if (err) return handleError(err)
db.query('SELECT * FROM users', (err, users) => {
if (err) return handleError(err)
sendResponse(users, (err) => {
if (err) return handleError(err)
console.log('Done!')
// The pyramid of doom
})
})
})
})
})This is exactly the problem that Promises were created to solve.
Part 2: Promises
A Promise is an object that represents the eventual result of an async operation. It can be in one of three states:
- Pending — operation has not completed yet
- Fulfilled — operation completed successfully, value is available
- Rejected — operation failed, reason (error) is available
Creating a Promise
const myPromise = new Promise((resolve, reject) => {
// Async work here
setTimeout(() => {
const success = true
if (success) {
resolve('Operation succeeded!') // fulfil with a value
} else {
reject(new Error('Operation failed')) // reject with an error
}
}, 1000)
})Consuming a Promise
myPromise
.then(result => {
console.log(result) // 'Operation succeeded!'
})
.catch(err => {
console.error(err.message)
})
.finally(() => {
console.log('Always runs, success or failure')
})Promise Chaining — No More Pyramid
The key advantage of Promises is chaining — each .then() returns a new Promise, so you can sequence async operations without nesting:
// ✅ Promise chain — flat and readable
readFilePromise('config.json')
.then(config => parseConfig(config))
.then(parsed => connectToDatabase(parsed.db))
.then(db => db.query('SELECT * FROM users'))
.then(users => sendResponse(users))
.then(() => console.log('Done!'))
.catch(err => handleError(err)) // ONE catch handles ALL errors in the chainEach .then() receives the resolved value of the previous Promise. A single .catch() at the end handles any error from any step.
Promisifying Callback-Based APIs
Many older Node.js APIs use callbacks. You can wrap them in Promises manually:
const fs = require('fs')
function readFilePromise(path, encoding = 'utf8') {
return new Promise((resolve, reject) => {
fs.readFile(path, encoding, (err, data) => {
if (err) reject(err)
else resolve(data)
})
})
}
readFilePromise('./data.txt')
.then(data => console.log(data))
.catch(err => console.error(err))Or use Node's built-in util.promisify:
const { promisify } = require('util')
const fs = require('fs')
const readFile = promisify(fs.readFile)
readFile('./data.txt', 'utf8')
.then(data => console.log(data))
.catch(err => console.error(err))Even better — Node.js ships promise-native versions of the fs module:
const fs = require('fs/promises') // Promise-based fs
fs.readFile('./data.txt', 'utf8')
.then(data => console.log(data))
.catch(err => console.error(err))Running Promises Concurrently
One of Promises' most powerful features is running multiple async operations at the same time:
Promise.all — All must succeed
const [users, products, orders] = await Promise.all([
db.query('SELECT * FROM users'),
db.query('SELECT * FROM products'),
db.query('SELECT * FROM orders'),
])
// All three queries run concurrently — total time = slowest query, not sum of allIf any Promise rejects, Promise.all immediately rejects with that error.
Promise.allSettled — Wait for all, regardless of outcome
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(2),
fetchUser(-1), // will reject
])
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value)
} else {
console.error('Failed:', result.reason.message)
}
})Promise.race — First to resolve or reject wins
const result = await Promise.race([
fetch('https://api-primary.example.com/data'),
fetch('https://api-backup.example.com/data'),
])
// Whichever API responds first winsPromise.any — First to resolve wins (ignores rejections)
const result = await Promise.any([
fetchFromServer1(),
fetchFromServer2(),
fetchFromServer3(),
])
// Returns first success; only rejects if ALL fail (AggregateError)Part 3: Async/Await
async/await is syntactic sugar over Promises. It makes async code look and read like synchronous code — without actually being synchronous. It is the preferred pattern in modern Node.js.
The async Keyword
Adding async to a function makes it return a Promise automatically:
async function greet() {
return 'Hello!' // automatically wrapped in Promise.resolve()
}
greet().then(console.log) // Hello!The await Keyword
await pauses execution inside an async function until the Promise resolves, then returns the resolved value:
async function readConfig() {
const fs = require('fs/promises')
const data = await fs.readFile('config.json', 'utf8') // waits here
const config = JSON.parse(data)
return config
}await can only be used inside an async function (or at the top level of an ES module).
Rewriting the Callback Hell Example
const fs = require('fs/promises')
// ✅ async/await — reads like synchronous code
async function bootstrap() {
const config = await fs.readFile('config.json', 'utf8')
const parsed = await parseConfig(config)
const db = await connectToDatabase(parsed.db)
const users = await db.query('SELECT * FROM users')
await sendResponse(users)
console.log('Done!')
}
bootstrap()Same logic as the callback hell example — but completely flat and readable.
Error Handling with try/catch
async function fetchUserData(id) {
try {
const user = await db.findUser(id)
const posts = await db.findPosts(user.id)
return { user, posts }
} catch (err) {
console.error('Failed to fetch user data:', err.message)
throw err // re-throw so the caller can handle it
}
}The to() Helper — Avoiding try/catch Everywhere
Wrapping every await in try/catch becomes repetitive. A popular pattern is a to() helper that returns [error, result] tuples:
// utils/to.js
async function to(promise) {
try {
const result = await promise
return [null, result]
} catch (err) {
return [err, null]
}
}
module.exports = toconst to = require('./utils/to')
async function getUser(id) {
const [err, user] = await to(db.findUser(id))
if (err) {
console.error('DB error:', err.message)
return null
}
return user
}This is especially clean in Express route handlers where you want concise error handling without deeply nested try/catch.
Sequential vs Concurrent with Async/Await
Sequential — each awaits the previous (slower):
// ❌ Sequential — total time = sum of all three
const users = await fetchUsers() // 200ms
const products = await fetchProducts() // 150ms
const orders = await fetchOrders() // 100ms
// Total: ~450msConcurrent — all start at the same time (faster):
// ✅ Concurrent — total time = slowest one
const [users, products, orders] = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders(),
])
// Total: ~200msThis is one of the most impactful performance improvements you can make in any Node.js application. When async operations are independent of each other, always run them concurrently.
Top-Level Await (ES Modules)
In ES Modules, you can use await at the top level without wrapping in an async function:
// index.mjs — or .js with "type": "module"
import { readFile } from 'fs/promises'
const config = await readFile('config.json', 'utf8')
console.log(JSON.parse(config))This is particularly useful for application bootstrap code.
Putting It All Together: A Real Example
Here is a realistic function that fetches a user and their recent posts from a database, with proper error handling and concurrent queries:
const db = require('./db')
const { promisify } = require('util')
async function getUserProfile(userId) {
// Validate input
if (!userId || typeof userId !== 'number') {
throw new Error('userId must be a positive number')
}
// Fetch user and posts concurrently
const [user, posts] = await Promise.all([
db.findUserById(userId),
db.findRecentPosts(userId, { limit: 5 }),
])
// Guard: user might not exist
if (!user) {
throw new Error(`User ${userId} not found`)
}
return {
id: user.id,
name: user.name,
email: user.email,
recentPosts: posts.map(p => ({
id: p.id,
title: p.title,
createdAt: p.created_at,
})),
}
}
// Usage
async function main() {
try {
const profile = await getUserProfile(42)
console.log(profile)
} catch (err) {
console.error('Error fetching profile:', err.message)
process.exit(1)
}
}
main()This pattern — async function, concurrent Promise.all, guard clauses, and a single try/catch at the call site — is the standard you will use throughout this course.
Cheat Sheet: Callbacks vs Promises vs Async/Await
| Callbacks | Promises | Async/Await | |
|---|---|---|---|
| Syntax | fn(arg, (err, res) => {}) | .then().catch() | await fn() in try/catch |
| Error handling | Manual if (err) check | .catch() | try/catch |
| Chaining | Nesting (pyramid of doom) | .then() chains | Sequential await lines |
| Concurrency | Manual with counters | Promise.all() | await Promise.all() |
| Readability | Poor (nested) | Good (flat chain) | Excellent (sync-like) |
| Debugging | Hard (async stack traces) | Better | Best |
| When to use | Legacy code, streams | Utility functions | Everything else |
Node.js Full‑Stack Course — Module 6 of 32
You now command all three async patterns in Node.js. Continue to Module 7 to put them to work with the Node.js File System module.
Summary
Async programming is the core skill of every Node.js developer. Here is what to take away:
- Callbacks — the original pattern, error-first convention, prone to nesting hell
- Promises — chainable, single
.catch(), run concurrently withPromise.all/allSettled/race/any - Async/await — the modern standard, sync-like syntax,
try/catchfor errors util.promisifyandfs/promisesconvert callback APIs to Promises- Always run independent async operations concurrently with
Promise.all - Use
try/catchat the top level; re-throw errors so callers can handle them
Continue to Module 7: Working with the Node.js File System (fs module) →
Content powered by SearchFit.ai — for automated content at scale, visit https://searchfit.ai
