async and await
Almost everything useful a Node.js program does — reading a file, querying a database, calling an HTTP API — takes time, and Node.js runs that work asynchronously so the single JavaScript thread is never blocked while it waits. async and await are the syntax for writing that asynchronous code so it reads top-to-bottom like ordinary synchronous code, instead of nesting callbacks or chaining .then() calls. They don’t change how the event loop works underneath — they are built entirely on top of Promises, and simply let you “pause” a function at an await until a Promise settles. Understanding exactly what that pause does, and does not do, is essential for writing correct, non-blocking Node.js programs.
Overview: How async/await Works Under the Hood
A function declared with the async keyword always returns a Promise, even when the function body has a plain return value; statement — Node automatically wraps that value in Promise.resolve(value). If the function throws, the returned Promise rejects with that error instead of throwing synchronously to the caller. This single rule is what makes async functions composable: you always know you’re getting a Promise back, so you can await it or attach .then()/.catch() to it.
Inside an async function, await can only be applied to a Promise (or any “thenable” object with a .then() method). When execution reaches an await, three things happen: the expression to the right is evaluated to get a Promise, the rest of the async function is suspended, and control returns immediately to whatever called the function — nothing blocks. The calling code keeps running synchronously as if the async function had returned right there. Only later, when the awaited Promise settles, does the JavaScript engine schedule the rest of the function to resume, and it does so as a microtask.
Microtasks matter because Node’s event loop, built on the libuv library, processes work in distinct phases — timers, pending callbacks, poll, check (where setImmediate callbacks run), and close callbacks. Promise callbacks (and by extension, the continuations after every await) are not one of those phases; they run in a separate microtask queue that is drained completely after the current synchronous call stack finishes, and again after every callback in every phase. process.nextTick callbacks run even before promise microtasks. This is why an await continuation can “jump the queue” ahead of a setTimeout callback even if the timer’s delay has already elapsed — see the ordering example below.
Crucially, await does not make I/O synchronous and does not create a new thread. Filesystem, network and DNS operations that Node performs asynchronously (via libuv’s thread pool or the OS’s native async APIs) run exactly the same way whether you use callbacks, .then(), or await — await only affects how your own JavaScript code is scheduled while it waits for the result.
| Style | Example | Notes |
|---|---|---|
| Callback | fs.readFile(path, (err, data) => { ... }) |
Oldest style; easy to nest into “callback hell” |
| Promise chain | fs.promises.readFile(path).then(data => ...) |
Flatter, but chains get hard to read with branching logic |
| async/await | const data = await fs.promises.readFile(path); |
Reads like synchronous code; use try/catch for errors |
Syntax
The general form of an async function and how await is used inside it:
// 1. async function declaration
async function loadData(id) {
const result = await fetchData(id);
return result;
}
// 2. async arrow function
const loadDataArrow = async (id) => {
const result = await fetchData(id);
return result;
};
// 3. immediately-invoked async function (IIFE) — useful for
// running await-based code at the top level of a CommonJS file
(async () => {
const result = await loadData(1);
console.log(result);
})();
- async — marks a function as asynchronous; it can then use
awaitinside its body and always returns a Promise. - await — pauses the enclosing async function (only that function, not the whole program) until the Promise to its right settles, then evaluates to the resolved value or throws the rejection reason.
- Return value — whatever the function
returns becomes the resolved value of the Promise the function returns. - Errors — a rejected awaited Promise throws inside the async function, so wrap awaits in
try/catchto handle them. - IIFE pattern — since CommonJS modules don’t support top-level
await, wrapping code in an immediately-invoked async arrow function is the standard workaround.
Examples
Example 1: Reading a config file with fs/promises
const fs = require("node:fs/promises");
async function readConfig(filePath) {
try {
const data = await fs.readFile(filePath, "utf8");
return JSON.parse(data);
} catch (err) {
console.error("Failed to read config:", err.message);
throw err;
}
}
async function main() {
const config = await readConfig("./config.json");
console.log("Loaded config:", config);
}
main();
Output:
Loaded config: { port: 3000, host: 'localhost' }
fs.readFile from node:fs/promises returns a Promise, so awaiting it inside the try block gives you the file’s contents (or throws if the file is missing or unreadable). Notice the try/catch — this is the async/await equivalent of the error-first callback check or a .catch() on a Promise chain, and it’s required here or a missing file would crash the process with an unhandled rejection.
Example 2: Running async work sequentially vs. concurrently
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function sequential() {
const a = await delay(500, "a");
const b = await delay(500, "b");
return [a, b];
}
async function parallel() {
const [a, b] = await Promise.all([delay(500, "a"), delay(500, "b")]);
return [a, b];
}
async function main() {
console.log("sequential:", await sequential());
console.log("parallel:", await parallel());
}
main();
Output:
sequential: [ 'a', 'b' ]
parallel: [ 'a', 'b' ]
Both functions print the same values, but they take very different amounts of time. sequential() awaits each delay() call one after another, so the second timer doesn’t even start until the first has finished — roughly 1000ms total. parallel() starts both timers at the same time by passing both Promises to Promise.all() and awaiting the combined result, so the total time is roughly 500ms, the length of the slower of the two. Whenever the async operations you’re awaiting don’t depend on each other’s results, run them with Promise.all() instead of awaiting them one at a time.
Example 3: A realistic startup script
const fs = require("node:fs/promises");
const path = require("node:path");
async function loadConfig(configPath) {
const absolutePath = path.resolve(configPath);
const raw = await fs.readFile(absolutePath, "utf8");
const config = JSON.parse(raw);
if (!config.port) {
throw new Error("config.json is missing a 'port' field");
}
return config;
}
async function main() {
try {
const config = await loadConfig("./config.json");
console.log(`Starting server on port ${config.port}`);
} catch (err) {
console.error("Startup failed:", err.message);
process.exit(1);
}
}
main();
Output:
Starting server on port 3000
This mirrors how real Node.js applications often start up: load and validate configuration before doing anything else, and fail loudly with a non-zero process.exit(1) if something is wrong, rather than continuing in a broken state. If config.json is missing or invalid, the catch block runs and prints “Startup failed: …” instead of the server line.
Step by Step: What Actually Happens
It helps to see the ordering explicitly. Consider:
console.log("1: start");
async function asyncFn() {
console.log("2: inside async function, before await");
await null;
console.log("4: inside async function, after await");
}
asyncFn();
console.log("3: after calling asyncFn");
Output:
1: start
2: inside async function, before await
3: after calling asyncFn
4: inside async function, after await
Walking through it: line 1 runs synchronously and logs immediately. Calling asyncFn() runs its body synchronously up to the first await — so “2” logs right away too, in the same tick. At await null, the function suspends and returns control to the caller, even though null is already a resolved value. The line after the call, “3”, is still running in the original synchronous flow, so it logs next. Only once the current synchronous code finishes does the JavaScript engine drain the microtask queue, resuming asyncFn and logging “4” last. If you replaced await null with a Promise resolved by setTimeout, “4” would still log last, but now after any other microtasks and after the current timer phase — timers are a separate, later phase of the event loop, not a microtask.
Common Mistakes
Mistake 1: Forgetting the await
async function getUser(id) {
const user = db.findUser(id); // missing await — user is a Promise, not the data
console.log(user.name); // undefined, or a crash if user is not an object
}
Without await, user holds the pending Promise object itself, not the resolved value, so user.name is undefined. This is one of the most common async/await bugs because the code still “runs” without throwing — it just silently produces the wrong result.
async function getUser(id) {
const user = await db.findUser(id);
console.log(user.name);
}
Mistake 2: Not handling rejected Promises
async function riskyOperation() {
const response = await fetch("https://api.example.com/data"); // no try/catch
return response.json();
}
riskyOperation();
If the network request fails or the server is unreachable, fetch‘s Promise rejects, the await throws inside riskyOperation, and because nothing calls .catch() on the Promise it returns, Node reports an unhandled promise rejection — which, depending on your Node version and flags, can terminate the process entirely.
async function riskyOperation() {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return await response.json();
} catch (err) {
console.error("riskyOperation failed:", err.message);
throw err;
}
}
Mistake 3: Using top-level await in a CommonJS file
const fs = require("node:fs/promises");
const data = await fs.readFile("./config.json", "utf8"); // SyntaxError
console.log(data);
Top-level await — an await that isn’t inside any function — is only legal in ES modules. In a CommonJS file (the default unless you use import/export or a "type": "module" package.json), this is a SyntaxError before the file even runs.
const fs = require("node:fs/promises");
async function main() {
const data = await fs.readFile("./config.json", "utf8");
console.log(data);
}
main();
Wrap the code in an async function main() { ... } main();, as shown throughout this lesson, or convert the file to an ES module (.mjs, or "type": "module" in package.json) where top-level await is supported directly.
Best Practices
- Always wrap
awaited calls that can fail intry/catch, or make sure the caller attaches a.catch()— anasyncfunction’s rejection has to be handled somewhere. - Use
Promise.all()(orPromise.allSettled()when some operations are allowed to fail) to run independent async operations concurrently instead of awaiting them one at a time. - Never use the
*Syncversions offsfunctions (likereadFileSync) inside a request handler or other hot path — they block the entire event loop for every connected client; usenode:fs/promiseswithawaitinstead. - Remember that an
asyncfunction always returns a Promise, including one that looks like it returns nothing — a caller that ignores that Promise will never see errors from it. - Don’t mix
.then()/.catch()chains andawaitin the same function; pick one style so the control flow stays readable. - Prefer converting a module to ESM (or wrapping in an async IIFE) over creative workarounds when you need top-level
awaitin what would otherwise be a CommonJS file. - Log or re-throw errors caught in a
catchblock — never swallow them silently, or failures become invisible.
Practice Exercises
- Write an
asyncfunctiondelayedGreeting(name, ms)that uses thedelay()helper from this lesson to waitmsmilliseconds and then returnsHello, name!. Call it withawaitand log the result. - You have three independent async functions,
getUser(id),getOrders(userId), andgetInvoices(userId), that all only needuserIdonce you already have it. Rewrite code that currently awaits them one after another so they run concurrently withPromise.all()instead, and log all three results together. - Take the “forgetting the await” mistake from this lesson, intentionally break it by removing the
await, run it, and observe whatuseractually logs. Then fix it and add atry/catchthat logs a friendly error message instead of letting an unhandled rejection crash the process.
Summary
asyncfunctions always return a Promise, automatically wrapping return values and turning thrown errors into rejections.awaitpauses only the currentasyncfunction, not the whole program, and resumes its continuation as a microtask once the awaited Promise settles.- Microtasks (promise continuations,
process.nextTick) run before the event loop moves on to timers orsetImmediatecallbacks, which changes ordering compared to what the code’s shape might suggest. - Always handle rejected Promises with
try/catcharoundawait, or unhandled rejections can crash the process. - Await independent operations together with
Promise.all()instead of one at a time to avoid unnecessary sequential delays. - Top-level
awaitis legal in ES modules but aSyntaxErrorin CommonJS, where it must be wrapped in anasyncfunction. async/awaitis syntax built on top of Promises — it does not change Node’s single-threaded, event-loop-based execution model.
