Skip to content

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 await must be inside a try/catch or wrapped by asyncHandler
  • Never use async functions inside forEach — 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 await inside 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()