Node.js – Async Code
Node.js is single-threaded. All I/O is asynchronous. Getting async patterns wrong causes silent failures, memory leaks, and race conditions that are extremely difficult to diagnose in production.
Core Rules
- Always use
async/await— no raw Promise chains, no callbacks in new code - Every
awaitmust be inside atry/catchor wrapped byasyncHandler - Never use
asyncfunctions insideforEach— errors are silently swallowed - Use
Promise.all()for independent parallel operations - Always set timeouts on external HTTP calls — never await indefinitely
- Use
Promise.allSettled()when partial failure is acceptable - Never
awaitinside a loop unless operations are genuinely sequential
async/await Over Callbacks and Chains
// ✅ Correct — async/await is readable and debuggable
const getOrderWithUser = async (orderId: string) => {
const order = await ordersRepository.findById(orderId)
if (!order) throw new NotFoundError(`Order not found: ${orderId}`)
const user = await usersRepository.findById(order.userId)
return { order, user }
}
// ❌ Incorrect — raw Promise chain, harder to read and debug
const getOrderWithUser = (orderId: string) =>
ordersRepository
.findById(orderId)
.then(order => {
if (!order) throw new NotFoundError(`Order not found: ${orderId}`)
return usersRepository.findById(order.userId).then(user => ({ order, user }))
})
// ❌ Incorrect — callback style, never use in new code
ordersRepository.findById(orderId, (err, order) => {
if (err) return next(err)
usersRepository.findById(order.userId, (err, user) => {
if (err) return next(err)
res.json({ order, user })
})
})
Never Use async Inside forEach
forEach does not await the returned promises. Errors are silently lost and execution continues before async operations complete.
// ❌ Incorrect — errors swallowed, forEach does not await
items.forEach(async (item) => {
await inventoryService.reserveItem(item) // errors disappear silently
})
// ✅ Correct — sequential: use for...of when order matters
for (const item of items) {
await inventoryService.reserveItem(item)
}
// ✅ Correct — parallel: use Promise.all when order does not matter
await Promise.all(items.map(item => inventoryService.reserveItem(item)))
Sequential vs Parallel — Know the Difference
Running independent operations sequentially wastes time. Always identify whether operations depend on each other before deciding how to await them.
// ❌ Incorrect — sequential, each waits for the previous to complete
// Takes total time = getUser + getOrders + getPreferences
const user = await usersRepository.findById(userId)
const orders = await ordersRepository.findByUser(userId)
const preferences = await preferencesRepository.findByUser(userId)
// ✅ Correct — parallel, all run concurrently
// Takes total time = max(getUser, getOrders, getPreferences)
const [user, orders, preferences] = await Promise.all([
usersRepository.findById(userId),
ordersRepository.findByUser(userId),
preferencesRepository.findByUser(userId),
])
// ✅ Correct — sequential when the second call depends on the first
const order = await ordersRepository.findById(orderId)
const payment = await paymentsRepository.findByOrder(order.id) // needs order.id
Promise.allSettled — When Partial Failure Is Acceptable
Use Promise.all() when all operations must succeed. Use Promise.allSettled() when some can fail without aborting the whole operation.
// ✅ Promise.all — all must succeed (payment + inventory + email)
const [payment, reservation] = await Promise.all([
paymentsService.charge(order),
inventoryService.reserve(order.items),
])
// If either fails, the whole operation fails — which is correct here
// ✅ Promise.allSettled — partial success is acceptable (notifications)
const notificationResults = await Promise.allSettled([
emailService.send(user.email, orderConfirmation),
smsService.send(user.phone, orderConfirmation),
pushService.send(user.deviceToken, orderConfirmation),
])
// Log failures without aborting — notification delivery is best-effort
notificationResults
.filter(r => r.status === 'rejected')
.forEach(r => logger.warn({ reason: r.reason }, 'Notification failed'))
Timeouts on External Calls
Always set a timeout on every external HTTP call. An external service that never responds will cause your request handler to hang indefinitely, eventually exhausting the connection pool.
// ✅ Correct — timeout with AbortController (Node.js 18+)
const fetchShippingRates = async (postcode: string): Promise<ShippingRate[]> => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5_000) // 5s timeout
try {
const response = await fetch(`${SHIPPING_API_URL}/rates?postcode=${postcode}`, {
signal: controller.signal,
})
if (!response.ok) {
throw new ExternalServiceError(`Shipping API returned ${response.status}`)
}
return response.json() as Promise<ShippingRate[]>
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new ExternalServiceTimeoutError('Shipping API timed out after 5s')
}
throw error
} finally {
clearTimeout(timeoutId)
}
}
// ✅ Correct — timeout with axios
import axios from 'axios'
const fetchShippingRates = async (postcode: string): Promise<ShippingRate[]> => {
const { data } = await axios.get(`${SHIPPING_API_URL}/rates`, {
params: { postcode },
timeout: 5_000, // 5s timeout — throws AxiosError with code ECONNABORTED
})
return data
}
Controlling Concurrency
Promise.all() launches all operations simultaneously. For large arrays, this can overwhelm a database or external service. Use a concurrency-limited batch approach for large-scale operations.
// ❌ Incorrect — fires 10,000 DB queries simultaneously
await Promise.all(orders.map(order => processOrder(order)))
// ✅ Correct — process in batches of 10 concurrently
const processBatch = async <T>(
items: T[],
processor: (item: T) => Promise<void>,
concurrency = 10
): Promise<void> => {
const queue = [...items]
const workers = Array.from({ length: concurrency }, async () => {
while (queue.length > 0) {
const item = queue.shift()
if (item !== undefined) await processor(item)
}
})
await Promise.all(workers)
}
// Usage
await processBatch(orders, processOrder, 10)
// ✅ Simple chunk-based batching for smaller arrays
const chunk = <T>(arr: T[], size: number): T[][] =>
Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
arr.slice(i * size, i * size + size)
)
for (const batch of chunk(orders, 50)) {
await Promise.all(batch.map(order => processOrder(order)))
}
Retry with Exponential Backoff
Never retry immediately — this can amplify load on a struggling service. Use exponential backoff with jitter.
interface RetryOptions {
maxAttempts?: number
baseDelayMs?: number
maxDelayMs?: number
}
const withRetry = async <T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> => {
const { maxAttempts = 3, baseDelayMs = 200, maxDelayMs = 5_000 } = options
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt === maxAttempts) break
// Exponential backoff with jitter
const delay = Math.min(
baseDelayMs * 2 ** (attempt - 1) + Math.random() * 100,
maxDelayMs
)
logger.warn({ attempt, delay, error }, 'Retrying after failure')
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw lastError
}
// Usage
const rates = await withRetry(
() => shippingApi.getRates(postcode),
{ maxAttempts: 3, baseDelayMs: 500 }
)
asyncHandler — Eliminating try/catch Boilerplate
Wrap every async Express route handler with asyncHandler to automatically forward errors to the centralised error middleware, without repeating try/catch in every controller.
// utils/asyncHandler.ts
import type { Request, Response, NextFunction, RequestHandler } from 'express'
type AsyncRequestHandler = (
req: Request,
res: Response,
next: NextFunction
) => Promise<void>
export const asyncHandler = (fn: AsyncRequestHandler): RequestHandler =>
(req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
// ✅ Correct — asyncHandler catches errors and forwards to error middleware
export const getOrder = asyncHandler(async (req, res) => {
const order = await ordersService.getOrder(req.params.id)
res.json({ data: order })
})
// ❌ Incorrect — try/catch in every controller is repetitive
export const getOrder = async (req: Request, res: Response, next: NextFunction) => {
try {
const order = await ordersService.getOrder(req.params.id)
res.json({ data: order })
} catch (error) {
next(error)
}
}
Common Async Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
async inside forEach |
Errors swallowed, no awaiting | Use for...of or Promise.all(.map(...)) |
await in a loop for independent ops |
Slower than necessary | Use Promise.all() |
| No timeout on external calls | Hanging requests, exhausted pool | Always pass signal or timeout |
Missing await before a promise |
Returns Promise<T> not T |
Add await or return the promise |
Promise.all with no concurrency limit |
Overwhelms DB / external service | Use batch processing |
| Catching errors without rethrowing | Silent failure | Rethrow or pass to next() |