Back/Module F-4 Async JavaScript: Callbacks, Promises, and the Event Loop Intro
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
const fs = require('node:fs'); // readFile takes a path, options, and a callback fs.readFile('./users.json', 'utf8', function(err, data) { // This function runs AFTER the file has been read if (err) { console.error('Error reading file:', err.message); return; } const users = JSON.parse(data); console.log('Loaded', users.length, 'users'); }); // This line runs IMMEDIATELY — before the file is read console.log('Waiting for file...');

Output:

Waiting for file...
Loaded 42 users

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
function handleResult(err, result) { if (err) { // Handle the error and return — don't fall through console.error(err); return; } // Only reach here if there was no error console.log(result); }

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
fs.readFile('./config.json', 'utf8', function(err, configData) { if (err) return console.error(err); const config = JSON.parse(configData); db.query('SELECT * FROM users WHERE active = $1', [true], function(err, users) { if (err) return console.error(err); sendEmail(users[0].email, 'Welcome', config.welcomeMessage, function(err) { if (err) return console.error(err); fs.writeFile('./sent.log', users[0].email + '\n', { flag: 'a' }, function(err) { if (err) return console.error(err); console.log('Done!'); // We are now 4 levels deep — this is "callback hell" }); }); }); });

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
// A function that returns a Promise function readJsonFile(filePath) { return new Promise((resolve, reject) => { fs.readFile(filePath, 'utf8', (err, data) => { if (err) { reject(err); // Operation failed return; } try { resolve(JSON.parse(data)); // Operation succeeded } catch (parseErr) { reject(parseErr); } }); }); }

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

javascript
readJsonFile('./config.json') .then(config => { console.log('Config loaded:', config); return config; // Pass to the next .then() }) .then(config => { console.log('Port:', config.port); }) .catch(err => { // Catches errors from ANY .then() above console.error('Something went wrong:', err.message); }) .finally(() => { // Always runs, whether success or failure console.log('Done'); });

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
// Each .then() can return a new Promise — they chain automatically readJsonFile('./config.json') .then(config => db.query('SELECT * FROM users WHERE active = $1', [true])) .then(users => sendEmail(users[0].email, 'Welcome', 'Hello!')) .then(() => fs.promises.appendFile('./sent.log', 'email sent\n')) .then(() => console.log('Done!')) .catch(err => console.error('Failed:', err.message));

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
// Run all in parallel, wait for all to succeed // Fails fast if any one rejects const [users, products, config] = await Promise.all([ db.query('SELECT * FROM users'), db.query('SELECT * FROM products'), readJsonFile('./config.json'), ]); // Wait for all, don't fail if some reject const results = await Promise.allSettled([ fetchFromServiceA(), fetchFromServiceB(), fetchFromServiceC(), ]); results.forEach(result => { if (result.status === 'fulfilled') { console.log('Success:', result.value); } else { console.error('Failed:', result.reason); } }); // Race — resolves/rejects with whichever finishes first const fastest = await Promise.race([ fetchFromRegion('us-east'), fetchFromRegion('eu-west'), ]); // First to SUCCEED (ignores rejections until all reject) const firstSuccess = await Promise.any([ attemptMethod1(), attemptMethod2(), attemptMethod3(), ]);

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
// This function returns a Promise automatically async function loadAndProcessUsers() { // await pauses HERE until the Promise resolves const config = await readJsonFile('./config.json'); // Execution continues here after the file is read const users = await db.query('SELECT * FROM users WHERE active = $1', [true]); return users.filter(u => u.email.endsWith(config.domain)); } // Call it like a regular function, but it returns a Promise loadAndProcessUsers() .then(users => console.log(users)) .catch(err => console.error(err)); // Or await it inside another async function async function main() { const users = await loadAndProcessUsers(); console.log(users); }

Error handling with try/catch

javascript
async function processOrder(orderId) { try { const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId]); if (!order) throw new Error(`Order ${orderId} not found`); await chargePayment(order.amount, order.paymentMethod); await sendConfirmationEmail(order.userEmail); await db.query('UPDATE orders SET status = $1 WHERE id = $2', ['completed', orderId]); return { success: true }; } catch (err) { // Catches any error from any await above console.error('Order processing failed:', err.message); // You can re-throw, return an error object, or handle here return { success: false, error: err.message }; } }

Common async/await mistakes

Forgetting await:

javascript
// ❌ WRONG — result is a Promise object, not the data async function getUser(id) { const user = db.findById(id); // Missing await! console.log(user.name); // TypeError: Cannot read 'name' of Promise } // ✅ CORRECT async function getUser(id) { const user = await db.findById(id); console.log(user.name); }

Sequential when you should be parallel:

javascript
// ❌ SLOW — these have no dependency on each other but run sequentially async function loadDashboard(userId) { const user = await db.getUser(userId); // waits 10ms const orders = await db.getOrders(userId); // waits 10ms AFTER user const messages = await db.getMessages(userId); // waits 10ms AFTER orders // Total: ~30ms } // ✅ FAST — run all three in parallel async function loadDashboard(userId) { const [user, orders, messages] = await Promise.all([ db.getUser(userId), db.getOrders(userId), db.getMessages(userId), ]); // Total: ~10ms (all run simultaneously) }

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

Unhandled rejections:

javascript
// ❌ WRONG — if this Promise rejects, it's unhandled async function background() { await someOperationThatMightFail(); } background(); // No .catch(), no try/catch in the caller // ✅ CORRECT — always handle rejections background().catch(err => console.error('Background task failed:', err)); // Or at the top level process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); process.exit(1); });

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.

Your Code:        │  Event Loop:         │  OS / libuv:
                  │                      │
await db.query()  │  → queue DB call     │  → TCP connection open
                  │  → handle request 2  │  → query sent
                  │  → handle request 3  │  → waiting for response
                  │  → run timer         │
                  │  ← DB response ready │  ← response arrived
resume from await │  ← run your callback │

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
const util = require('node:util'); const fs = require('node:fs'); const { exec } = require('node:child_process'); // Wrap callback-based functions const readFile = util.promisify(fs.readFile); const execAsync = util.promisify(exec); // Now use with async/await const data = await readFile('./package.json', 'utf8'); const { stdout } = await execAsync('git log --oneline -5'); console.log(stdout);

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
// main.mjs (or .js with "type": "module") import { readFile } from 'node:fs/promises'; // Top-level await — works in ESM const config = await readFile('./config.json', 'utf8'); console.log(JSON.parse(config));

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

javascript
// main.js (CommonJS) async function main() { const fs = require('node:fs/promises'); const config = await fs.readFile('./config.json', 'utf8'); console.log(JSON.parse(config)); } main().catch((err) => { console.error(err); process.exit(1); });

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.

Discussion