Node.jsBackendFull-Stack

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

TT
TopicTrick Team
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):

javascript
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:

  1. First argument is always errnull if successful, an Error object if not
  2. Subsequent arguments carry the result data
  3. Always check err before using the result

Reading a File with Callbacks

javascript
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:

text
This runs BEFORE the file is read
File contents: Hello from data.txt

The 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

javascript
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:

javascript
// ❌ 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

javascript
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

javascript
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:

javascript
// ✅ 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 chain

Each .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:

javascript
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:

javascript
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:

javascript
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

javascript
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 all

If any Promise rejects, Promise.all immediately rejects with that error.

Promise.allSettled — Wait for all, regardless of outcome

javascript
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

javascript
const result = await Promise.race([
    fetch('https://api-primary.example.com/data'),
    fetch('https://api-backup.example.com/data'),
])
// Whichever API responds first wins

Promise.any — First to resolve wins (ignores rejections)

javascript
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:

javascript
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:

javascript
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

javascript
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

javascript
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:

javascript
// utils/to.js
async function to(promise) {
    try {
        const result = await promise
        return [null, result]
    } catch (err) {
        return [err, null]
    }
}

module.exports = to
javascript
const 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):

javascript
// ❌ Sequential — total time = sum of all three
const users = await fetchUsers()       // 200ms
const products = await fetchProducts() // 150ms
const orders = await fetchOrders()     // 100ms
// Total: ~450ms

Concurrent — all start at the same time (faster):

javascript
// ✅ Concurrent — total time = slowest one
const [users, products, orders] = await Promise.all([
    fetchUsers(),
    fetchProducts(),
    fetchOrders(),
])
// Total: ~200ms

This 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:

javascript
// 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:

javascript
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

CallbacksPromisesAsync/Await
Syntaxfn(arg, (err, res) => {}).then().catch()await fn() in try/catch
Error handlingManual if (err) check.catch()try/catch
ChainingNesting (pyramid of doom).then() chainsSequential await lines
ConcurrencyManual with countersPromise.all()await Promise.all()
ReadabilityPoor (nested)Good (flat chain)Excellent (sync-like)
DebuggingHard (async stack traces)BetterBest
When to useLegacy code, streamsUtility functionsEverything 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 with Promise.all/allSettled/race/any
    • Async/await — the modern standard, sync-like syntax, try/catch for errors
    • util.promisify and fs/promises convert callback APIs to Promises
    • Always run independent async operations concurrently with Promise.all
    • Use try/catch at 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