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.
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.
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.
Analogy: A callback is a claim ticket at a dry cleaner — you hand off the work and walk away, and the shop calls your number when it's done instead of making you stand at the counter.
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.
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); }
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 only works if every .then() that starts new async work actually returns the Promise it creates. Forget the return, and the chain silently stops waiting for that step:
// ❌ WRONG — the chain doesn't wait for sendEmail to finish readJsonFile('./config.json') .then(config => { db.query('SELECT * FROM users WHERE active = $1', [true]) .then(users => { sendEmail(users[0].email, 'Welcome', config.welcomeMessage); // no return! }); // Execution falls through to here immediately — sendEmail may still be in flight }) .then(() => console.log('Done!')) // This can log BEFORE the email actually sends .catch(err => console.error('Failed:', err.message)); // This won't catch a sendEmail failure
// ✅ CORRECT — return the inner Promise so it joins the outer chain readJsonFile('./config.json') .then(config => { return db.query('SELECT * FROM users WHERE active = $1', [true]) .then(users => sendEmail(users[0].email, 'Welcome', config.welcomeMessage)); }) .then(() => console.log('Done!')) .catch(err => console.error('Failed:', err.message));
The nested, un-returned version isn't just slower to notice a bug in — it also breaks error handling: a rejection from the inner Promise never reaches the outer .catch(), because the outer chain never linked to it in the first place. The fix is always the same: if a .then() callback kicks off async work, return it.
When you need multiple operations to run at the same time and wait for all of them:
// 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(), ]);
// 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(), ]);
From production: A blockchain indexer needed the balances of 500 addresses and used Promise.all to fetch them in parallel — the pattern shown above for users/products/config. One of the 500 RPC calls to the node provider failed. Promise.all does exactly what it's documented to do: it rejects as soon as any one input rejects, immediately, without waiting for or reporting on the others. The other 499 calls had already succeeded by then — real answers, sitting in memory — but Promise.all's rejection discarded all of them, and the whole 500-address batch had to be retried from scratch. Every successful RPC call in that batch had burned a unit of the provider's rate-limited quota for nothing. The fix was to switch to Promise.allSettled, which waits for every Promise to settle (success or failure) and hands back a per-item result — so the 499 successes could be kept and only the one failed address needed a retry.
A related trap with Promise.race and Promise.any: both combinators resolve as soon as one input Promise settles favorably, but the other Promises you passed in don't stop running — they're still in flight. If one of those "losing" Promises later rejects, and nothing else is attached to it, you get an unhandled rejection even though your code looks like it handled the outcome correctly. This is easy to miss because the race/any call itself succeeded. If you fire off Promises you don't otherwise track, attach a no-op .catch() to each so a late rejection doesn't surface as an unhandled rejection warning (or crash your process, depending on your unhandledRejection handling).
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.
// 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]);
// 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); }
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 }; } }
A subtlety worth internalising: notice that if (!order) throw new Error(...) uses a plain synchronous throw — not reject(). Inside an async function, that's fine: a synchronous throw doesn't produce an uncaught exception that crashes your process. Because an async function always returns a Promise, JavaScript automatically converts any thrown error (synchronous or not) into that Promise's rejection. The try/catch above catches it exactly the same way it catches a rejected await. This is different from a plain (non-async) function, where an uncaught synchronous throw really can crash the process if nothing catches it.
// ❌ 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:
// ❌ 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:
// ❌ WRONG — if this Promise rejects, it's unhandled async function background() { await someOperationThatMightFail(); } background(); // No .catch(), no try/catch in the caller
Everything so far assumes an async operation, once started, runs to completion. But sometimes you need to cancel it early — a user navigates away before a fetch finishes, or a request handler times out and you don't want to keep waiting on a slow downstream call. AbortController is the standard (browser and Node.js) mechanism for this:
const controller = new AbortController();
// Give the request 3 seconds, then cancel it const timeout = setTimeout(() => controller.abort(), 3000);
try { const response = await fetch('https://api.example.com/slow-endpoint', { signal: controller.signal, }); const data = await response.json(); console.log(data); } catch (err) { if (err.name === 'AbortError') { console.error('Request timed out and was aborted'); } else { throw err; } } finally { clearTimeout(timeout); }
controller.signal is an AbortSignal you pass to any API that supports cancellation (fetch, many database drivers, Node's own fs and stream APIs). Calling controller.abort() notifies every consumer of that signal to stop what it's doing. You don't need to reach for this in every script, but as soon as you're handling requests with timeouts, this is the mechanism Node.js and the web platform standardised on — not a bespoke cancelled flag you check manually.
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:
Hands the database call to libuv (the C library beneath Node.js)
libuv tells the OS "make a TCP connection, send this query, call me when you have a response"
The event loop continues — handles other requests, runs timers, processes other callbacks
When the OS signals the database has responded, libuv queues the callback
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 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.
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.