Module F-4·22 min read

The callback pattern, callback hell, Promises, async/await, Promise.all — and a first look at why Node.js never blocks, without the deep internals.

Module F-4 — Async JavaScript: Callbacks, Promises, and the Event Loop Intro

What this module covers: Asynchronous programming is the single hardest concept for developers coming to Node.js. Not because the ideas are complicated — they aren't — but because Node.js inherited a pile of historical patterns (callbacks, then Promises, then async/await) and you will encounter all three in the wild. This module explains all of them, why each one exists, and how to think about the event loop without needing to know its internals yet. The deep event loop mechanics are in Phase 3. Here you get the mental model that makes everything else in Phase 1 and 2 work.


Why Asynchronous Programming Exists

Consider this: you ask a database for some rows. On a fast network, the database responds in 1–5 milliseconds. In that 1–5ms, your CPU is not computing anything — it is idle, waiting for the network packet to arrive.

In a traditional synchronous program (or a thread-per-request server), that thread just sits there blocked. It cannot handle anything else while it waits. If 100 requests all arrive at once and each takes 5ms of I/O, you need 100 threads running simultaneously.

Node.js takes a different approach: register a callback or a Promise, then go do other work while waiting. When the I/O completes, execute the registered function. One thread. No waiting. This is the core idea.

You do not need to understand how the event loop implements this at a low level right now — that is Phase 3. What you need to understand now is the programming model: how you write code that does not block.


The Callback Pattern

Callbacks are the original mechanism for async in Node.js. A callback is simply a function you pass as an argument, to be called when some work is done.

javascript

Output:

text

The key thing: code after fs.readFile() does not wait for the file. Node.js fires off the file read, moves on, and calls your callback later when the data is ready.

The error-first convention: Node.js callbacks always receive (err, result). If err is not null, something went wrong. Always check it first.

javascript

The Problem: Callback Hell

Callbacks work fine for a single operation. Problems start when you need to chain operations — do this, then that, then the other thing:

javascript

This rightward drift — each operation nesting inside the previous one — is called callback hell or the pyramid of doom. It is:

  • Hard to read
  • Hard to maintain
  • Hard to add error handling to each level
  • Impossible to easily add parallel operations

Promises were invented to solve this.


Promises

A Promise is an object that represents the eventual result of an asynchronous operation. It is either:

  • Pending — the operation is still in progress
  • Fulfilled — the operation completed successfully, and the Promise has a value
  • Rejected — the operation failed, and the Promise has a reason (an error)
javascript

Using a Promise with .then() and .catch()

javascript

The key improvement over callbacks: errors flow to a single .catch() instead of needing to be checked at every level. And .then() chains are flat rather than nested.

Chaining Promises (the right way)

javascript

Flat. Readable. One .catch() handles any failure in the chain.

Promise combinators

When you need multiple operations to run at the same time and wait for all of them:

javascript

async/await: The Modern Way

async/await is syntactic sugar over Promises. Under the hood, it is exactly the same — an async function returns a Promise, and await pauses execution of that function (not the entire process) until a Promise resolves.

javascript

Error handling with try/catch

javascript

Common async/await mistakes

Forgetting await:

javascript

Sequential when you should be parallel:

javascript

The rule: if operation B does not need the result of operation A, run them in parallel with Promise.all.

Unhandled rejections:

javascript

The Event Loop: A First Look

You do not need the full mechanics yet — that is Phase 3. But you need the mental model to understand why the code above works the way it does.

Node.js runs your JavaScript on a single thread. That thread runs a loop: check for work → run work → check for more work → run more work → repeat. This is the event loop.

When you await db.query(...), Node.js does not block the thread. It:

  1. Hands the database call to libuv (the C library beneath Node.js)
  2. libuv tells the OS "make a TCP connection, send this query, call me when you have a response"
  3. The event loop continues — handles other requests, runs timers, processes other callbacks
  4. When the OS signals the database has responded, libuv queues the callback
  5. The event loop picks it up, resumes your async function from the line after the await

The result: one thread can handle thousands of simultaneous I/O operations, because during the I/O wait it is doing other work.

text

This is why you should never do CPU-heavy work in Node.js without care — a long-running computation does block the event loop and stops everything else from running. More on this in Phase 3.


util.promisify: Converting Callbacks to Promises

Many older Node.js APIs and npm packages use the callback pattern. The util.promisify function wraps them to return a Promise:

javascript

util.promisify works with any function that follows the error-first callback convention (err, result) => {}. Modern Node.js modules (fs/promises, etc.) are already Promise-based, but you will encounter older libraries that need this treatment.


Practical Pattern: Top-Level async/await

In modern Node.js (v14.8+ with ESM, or inside any async function), you can use await at the top level of your entry file:

javascript

For CommonJS entry files, wrap in an immediately-invoked async function:

javascript

Always attach a .catch() to the top-level call so startup errors don't produce silent unhandled rejections.


Summary

  • Callbacks are functions passed as arguments, called when async work completes. They follow (err, result) convention. They still appear in older code and libraries — you need to be able to read them.
  • Promises represent a future value. .then() for success, .catch() for errors. They chain flat instead of nesting. Promise.all runs operations in parallel.
  • async/await is Promises with cleaner syntax. async functions return Promises. await pauses the function (not the process) until a Promise resolves. Use try/catch for error handling.
  • Use async/await for all new code. Fall back to .then()/.catch() when you can't use await (rare). Use util.promisify to wrap callback-based libraries.
  • Never block the event loop. Any synchronous operation that takes significant time will stop Node.js from handling other work. This is why I/O must be async and CPU-heavy work needs special handling.
  • Promise.all for parallel operations. If two operations don't depend on each other, run them simultaneously — it's often 2–5× faster than sequential awaits.

Next: npm and the ecosystem — package.json, semantic versioning, the lock file, and the tools every Node.js project installs on day one.


Knowledge Check

When fetching data from two independent services using async/await, what is the most efficient approach?


What happens if you forget to use the await keyword when calling an async function that queries a database?


How does the Node.js event loop handle a database query executed with await?

Test your knowledge with more question sets

Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.