async/await in Node
async/await makes asynchronous Node code read like synchronous code while keeping Promise semantics - the win is clarity if you preserve error context and avoid serializing independent I/O.
Recipe
async function fetchUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<{ id: string; name: string }>;
}
try {
const user = await fetchUser('42');
console.log(user.name);
} catch (err) {
console.error('fetchUser failed:', err);
}When to reach for this:
- HTTP handlers that chain database and API calls
- Scripts that read config, connect DB, then start server
- Replacing nested
.then()pyramids in brownfield code - Express 5 / Fastify 5 route handlers with async logic
Working Example
import { createServer } from 'node:http';
interface User { id: string; name: string }
const users = new Map<string, User>([['1', { id: '1', name: 'Ada' }]]);
async function getUser(id: string): Promise<User> {
await new Promise((r) => setTimeout(r, 10)); // simulate DB latency
const user = users.get(id);
if (!user) throw new Error(`User ${id} not found`);
return user;
}
const server = createServer(async (req, res) => {
try {
const id = req.url?.split('/').pop() ?? '';
const user = await getUser(id);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(user));
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const status = message.includes('not found') ? 404 : 500;
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
});
server.listen(3000);What this demonstrates:
asyncroute handlers return Promises - errors must be caught per requesttry/catcharoundawaitmaps failures to HTTP status codes- Throwing
Errorinstances preserves messages for clients and logs - Each request still shares one thread -
awaitdoes not block other requests unless you await serially inside a lock
Deep Dive
How It Works
async functionalways returns aPromise- even if youreturn 42.awaitpauses the function until the Promise settles, scheduling the remainder as microtasks.- Errors thrown inside
asyncfunctions become rejected Promises. - Top-level await in ESM modules delays importers until the module finishes initializing.
Parallel vs Sequential
// Slow: sequential (latency adds up)
const a = await fetchA();
const b = await fetchB();
// Fast: parallel independent I/O
const [a, b] = await Promise.all([fetchA(), fetchB()]);TypeScript Notes
// Typed Result pattern for expected failures (optional)
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
async function safeFetch(url: string): Promise<Result<unknown>> {
try {
const res = await fetch(url);
return { ok: true, value: await res.json() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
}
}Gotchas
- Unhandled async errors in HTTP handlers - Express 4 needed wrapping; still catch explicitly. Fix:
try/catchor framework error hooks (Express 5 improves this). - Sequential
awaitin loops -for (const id of ids) await fetch(id)is slow. Fix:Promise.allwith concurrency limit (p-limit). - Floating async I/O without await - fire-and-forget loses error context. Fix:
void doWork().catch(log)intentionally, or await. - Mixing callbacks and await awkwardly - promisify first. Fix:
util.promisifyorfs/promises. - Top-level await blocking importers - slow module init delays entire app boot. Fix: lazy dynamic
import()for heavy deps.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Raw Promises .then() | Interop with callback-style libraries | Complex branching readability suffers |
Promise.allSettled | Partial success batches | All must succeed atomically |
| Reactive streams (RxJS) | Event composition over time | Simple request/response handlers |
| Sync code | Pure CPU on small data | Any I/O or network |
FAQs
Does async block the event loop?
await suspends only the async function, not the thread. Other requests run until your continuation resumes as a microtask.
How do I handle errors in async Express 5 handlers?
Wrap in try/catch and call next(err), or use a wrapper that forwards rejections to error middleware.
Can I use await at module top level?
Yes in ESM ("type": "module"). Importers wait for the module to finish initializing.
What is floating Promise?
Calling an async function without await or .catch() - rejections may become unhandled.
How do I limit concurrency with Promise.all?
Use a pool library or batch ids into chunks instead of unbounded Promise.all on thousands of items.
Does await work with node:fs/promises?
Yes - all fs/promises methods return Promises suitable for await.
How do async generators fit in?
for await (const chunk of stream) consumes async iterables - common with streams and fetch bodies.
Should middleware be async?
Yes when it performs I/O. Ensure the framework awaits middleware return values (Express 5, Fastify 5 do).
What about Error cause?
throw new Error('DB failed', { cause: originalErr });Preserves nested stack context in Node 24.
Is async stack trace complete?
Generally yes with await. Deep callback mixes without promisify may still lose context.
How does Fastify 5 handle async handlers?
Return values and thrown errors integrate with Fastify's reply lifecycle - still prefer explicit validation at boundaries.
When to avoid async/await?
Hot paths with hundreds of microtasks where a state machine is clearer - rare in typical CRUD APIs.
Related
- Callbacks vs Promises Migration - brownfield conversion
- Microtasks vs Macrotasks - what await schedules
- Typing Express/Fastify Handlers - typed async routes
- Event Loop Best Practices - parallel I/O rules
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.