What you'll learn
By the end of this tutorial you'll understand why Node.js handles async work the way it does, and you'll know how to write it correctly. You'll move from callbacks through Promises to async/await with genuine confidence — not just the syntax, but the mental model behind why the code is structured that way. You'll also know when to run async tasks in sequence versus in parallel, and how to handle errors at each stage without letting one failure silently swallow the rest.
This isn't a survey of every async API in the Node.js docs. It's the minimum you need to read and write async Node.js code without it turning into a mess.
Who this is for
- Developers new to Node.js who keep running into async issues and can't fully explain why
- People who understand async/await syntax but aren't sure what happens underneath it
- Anyone who's written deeply nested callbacks or chained .then() calls and suspected there was a cleaner way
You can skip this if you already have a firm mental model of the Node.js event loop and use async/await and Promise.all confidently in production code. Come back if a specific pattern is giving you trouble — the troubleshooting section might help.
What is asynchronous programming in Node.js?
It's the way Node.js handles operations that take time — reading a file, querying a database, calling an API — without freezing the process while it waits.
Plain English: Node.js runs on a single thread. It can only do one thing at a time, but it never sits and waits. When it starts a slow operation, it registers a callback or a Promise, goes off to handle other work, and comes back when the result is ready. That's the event loop in one sentence.
Simple idea: think of it like ordering at a coffee counter. You give your order, move aside, and the barista calls your name when it's ready. You don't stand frozen at the counter while the espresso pulls. Node.js works the same way — it takes a request, hands it off, and processes the result when it arrives.
Prerequisites
- Node.js 20 or higher installed (
node -vto check) - Comfortable with JavaScript: functions, arrow functions, variables, and basic error handling
- A terminal and a code editor
No frameworks needed. The examples in this tutorial are plain Node.js scripts you can run directly.
Setup from zero
Step 1 — Create a test file
mkdir async-lab && cd async-lab
touch index.js
This is where all the examples in this tutorial live. Run each snippet with node index.js.
Step 2 — Confirm your Node version supports top-level await
node --version
Node 20 and above support top-level await in .mjs files and in .js files with "type": "module" in package.json. If you're on 18, everything works in functions — just avoid top-level await in .js files until you rename to .mjs.
Step 3 — Run a quick async sanity check
// index.js
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
Run it. The output is A, then C, then B. Even with a 0ms timeout, B logs last because setTimeout puts the callback at the end of the current event loop tick. If that result surprises you, this tutorial is exactly right for where you are.
> Little tip: when you're confused about the order async code runs, add console.log calls with labels at each step. The output order will usually tell you exactly which assumption is wrong — faster than reading docs.
The mental model
Node.js has one main thread and a queue of callbacks waiting to run. Here's what happens every time the event loop spins:
- Run all synchronous code in the current call stack until it's empty
- Process any resolved microtasks (Promise callbacks) — all of them, in order
- Pick one callback from the macrotask queue (timers, I/O completions, etc.) and run it
- Repeat
The key insight is that your async callbacks never interrupt synchronous code. If you're in the middle of a for loop over 10,000 items, no Promise resolves until that loop finishes. This is why long synchronous operations in Node.js block the whole process — the event loop can't get to the next callback until the synchronous work is done.
Practically: keep your synchronous work small. Let async operations handle the heavy lifting, because that's what the event loop was designed for.
Key terms
Callback — a function you pass to another function to be called when work is done. The original Node.js async pattern, still present throughout the standard library.
Promise — an object representing a value that will arrive later. Has three states: pending, fulfilled, rejected. You attach handlers with .then(), .catch(), and .finally().
async/await — syntax that makes Promise-based code look like synchronous code. async marks a function as returning a Promise. await pauses execution inside that function until a Promise settles.
Event loop — the mechanism that keeps Node.js running: checking for callbacks, running them, waiting for more.
Microtask queue — where resolved Promise callbacks go. Always drains before the next macrotask.
Macrotask queue — where timer callbacks and I/O completions go. One per loop tick.
Promise.all — runs multiple Promises in parallel and resolves when all resolve, or rejects as soon as one rejects.
Promise.allSettled — like Promise.all but never rejects; waits for every Promise to settle, giving you the result of each individually.
Step-by-step
Callbacks — the original pattern
const fs = require("fs");
fs.readFile("./notes.txt", "utf8", (err, data) => {
if (err) {
console.error("Read failed:", err.message);
return;
}
console.log(data);
});
The callback receives err as the first argument — this is the Node.js convention. Always check it before using the data.
The problem: nesting callbacks for sequential operations produces deeply indented code that's hard to read and error-prone. That pattern is what developers call callback hell.
Promises — a cleaner shape
const fs = require("fs/promises");
fs.readFile("./notes.txt", "utf8")
.then(data => console.log(data))
.catch(err => console.error("Read failed:", err.message));
fs/promises is a built-in module that gives you Promise-based versions of all the standard fs operations. Chains of .then() stay flat no matter how many steps you add.
async/await — the modern default
const fs = require("fs/promises");
async function readNotes() {
try {
const data = await fs.readFile("./notes.txt", "utf8");
console.log(data);
} catch (err) {
console.error("Read failed:", err.message);
}
}
readNotes();
await pauses readNotes until the file read settles. Other async work in the process continues normally during the wait — the event loop is not blocked.
Working examples
Sequential vs. parallel execution
const { setTimeout: sleep } = require("timers/promises");
// Sequential — total time: 3 seconds
async function sequential() {
await sleep(1000); // wait 1s
await sleep(2000); // wait 2s
console.log("Sequential done");
}
// Parallel — total time: 2 seconds (the longest one)
async function parallel() {
await Promise.all([sleep(1000), sleep(2000)]);
console.log("Parallel done");
}
When tasks are independent, run them in parallel with Promise.all. Sequential is for when step B genuinely depends on the result of step A.
Handling partial failures with Promise.allSettled
const results = await Promise.allSettled([
fetch("https://api.example.com/users"),
fetch("https://api.example.com/posts"),
fetch("https://api.example.com/tags"),
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("Success:", result.value.status);
} else {
console.error("Failed:", result.reason.message);
}
}
Use Promise.allSettled when you want to try multiple things and handle each outcome individually — one failure shouldn't stop you from processing the successful results.
> Little tip: Promise.all rejects as soon as any one Promise rejects. If you need the result of every request regardless of individual failures, reach for Promise.allSettled instead. Choosing the wrong one is one of the most common sources of silent data loss in Node.js code.
Patterns
Wrap callbacks in Promises when needed — for older Node.js APIs that only expose callbacks, util.promisify converts them to Promises in one line:
const { promisify } = require("util");
const { exec } = require("child_process");
const execAsync = promisify(exec);
const { stdout } = await execAsync("ls -la");
console.log(stdout);
Run independent queries in parallel — any time your code has two await calls that don't depend on each other, combine them with Promise.all:
// Slow: sequential
const user = await getUser(id);
const posts = await getPostsByUser(id);
// Fast: parallel
const [user, posts] = await Promise.all([getUser(id), getPostsByUser(id)]);
Common mistakes
Not catching rejected Promises — an unhandled rejection crashes the process in Node.js 15 and later. Every await in a non-trivial function should sit inside a try/catch, or the calling function should handle the rejection.
Using await inside a forEach loop — Array.forEach doesn't understand Promises. Async callbacks passed to forEach run concurrently with no way to await their completion. Use for...of for sequential async iteration, or Promise.all with .map() for parallel.
// Broken — awaits don't actually pause the loop
items.forEach(async (item) => {
await process(item); // fires concurrently, errors get swallowed
});
// Correct — sequential
for (const item of items) {
await process(item);
}
Troubleshooting
"Cannot use import statement in a module" — you're mixing ES module syntax (import) in a CommonJS file. Either rename to .mjs, add "type": "module" to package.json, or use require instead of import.
Process exits before async work finishes — a top-level async function wasn't awaited, so Node.js ran the synchronous wrapper and exited before the async work completed. Wrap the call in an IIFE: (async () => { await main(); })().
Unhandled promise rejection warning — a Promise rejected and nothing caught it. Add .catch() to the promise chain or wrap the relevant await in try/catch.
Checklist
- [ ] All
awaitcalls inside async functions are inside try/catch blocks or the caller handles errors - [ ] Independent async tasks use
Promise.allrather than sequentialawait - [ ] No
awaitinsideforEach— usingfor...oforPromise.allwith.map() - [ ] Callback-based APIs that need to compose with async code are wrapped with
util.promisify - [ ] Top-level async work is called and awaited before the process can exit
Practice task
Write a function called loadDashboard(userId) that fetches a user record, their last five posts, and an unread notification count — all three in parallel using Promise.all. Log all three results once they've resolved. Then modify it to use Promise.allSettled so a single failed fetch doesn't prevent the other two results from displaying.
FAQ
When should I use callbacks vs. Promises vs. async/await?
Async/await is the default for any new code you write. It's the most readable and the easiest to reason about. Use Promises directly when you need combinators like Promise.all or Promise.race. Use callbacks only when interfacing with older APIs that don't return Promises — and wrap them with util.promisify when possible.
Does await block the event loop?
No. await pauses the current async function, but it does not block the event loop. Other callbacks and Promises continue to be processed while your function waits for its Promise to settle. This is exactly the behavior you want.
What's the difference between Promise.all and Promise.allSettled?
Promise.all rejects immediately if any input rejects — the other Promises are ignored. Promise.allSettled waits for every input to settle and gives you the result of each, whether fulfilled or rejected. Use Promise.all when all inputs must succeed; use Promise.allSettled when you want to handle each outcome individually.
What to learn next
Once async patterns feel natural, the logical next steps are Node.js error handling (structuring try/catch across a whole application), Node.js streams (async data processing for large datasets), and Node.js security basics (input validation and safe async patterns in production).
Related on Baseline
- Node.js error handling — building on async patterns to handle failures at scale
- Node.js streams intro — the async pattern for large data
- Node.js testing with node:test — writing async tests for async code
Takeaways
Node.js handles slow work by queueing callbacks and coming back to them — the event loop is what makes that possible. Callbacks came first, Promises gave callbacks a composable shape, and async/await gave Promises a readable syntax. Use async/await for new code; use Promise.all when tasks are independent; use Promise.allSettled when you want every outcome regardless of failures.
If you remember only one thing: awaiting two independent async operations one after the other doubles your wait time for no reason. Any time two await calls don't depend on each other, combine them with Promise.all — the total time drops to the duration of the slowest one.