Skip to content

async_03 - Async/Await

Promises made async code readable. Async/await made it look synchronous
ES2017 introduced async and await keywords that let you write asynchronous code that reads like procedural code - no .then() chains , no callback nesting , just await the promise and keep going. Under the hood it's still Promises and the event loop. The syntax is just sugar , but god damn it's sweet sugar

async functions

An async function always returns a Promise - even if you return a string or nothing at all

async function greet(name) {
  return `hello ${name}`
}

// What you think it returns:
// 'hello mahmoud'

// What it actually returns:
// Promise { 'hello mahmoud' }

greet('mahmoud').then(console.log) // logs: 'hello mahmoud'

// inside an async function:
const result = await greet('mahmoud')
console.log(result) // 'hello mahmoud'

If an async function throws , it returns a rejected Promise. If it returns a value , it wraps it in a resolved Promise. This means every async function is awaitable by another async function

// async arrow functions
const fetchData = async (url) => {
  const response = await fetch(url)
  return response.json()
}

// async method in a class
class DatabaseService {
  async connect() {
    this.connection = await createConnection()
    return this.connection
  }

  async query(sql) {
    if (!this.connection) throw new Error('not connected')
    return this.connection.execute(sql)
  }
}

// async IIFE - run async code at module top level
;(async () => {
  const data = await fetchData('https://api.example.com')
  console.log(data)
})()

await - what it actually does

await pauses execution of the async function until the Promise settles
It does NOT block the event loop - while this async function is "paused" at an await, the event loop continues processing other requests, handling I/O callbacks , and running other JavaScript. The async function is suspended and its continuation is queued as a microtask when the awaited Promise resolves

console.log('1 - sync start')

async function demo() {
  console.log('2 - async function started')
  const result = await Promise.resolve('3 - awaited value')
  console.log(result)
  console.log('4 - after await')
}

demo()

console.log('5 - sync end')

// Output:
// 1 - sync start
// 2 - async function started
// 5 - sync end
// 3 - awaited value
// 4 - after await

Notice lines 3 and 4 run AFTER the synchronous code finishes. The await yields control back to the caller , and the rest of the async function runs as a microtask

try/catch with async/await

Regular try/catch works with async/await because the error is just a rejected Promise being thrown

const fs = require('fs/promises')

async function readConfig(path) {
  try {
    const data = await fs.readFile(path, 'utf8')
    return JSON.parse(data)
  } catch (err) {
    // err is either a filesystem error or a JSON parse error
    console.error('config read failed:', err.message)
    throw err // re-throw so the caller knows it failed
  }
}

You can also catch at the call site instead of inside the function:

async function main() {
  try {
    const config = await readConfig('/etc/app/config.json')
    console.log('config loaded:', config.host)
  } catch (err) {
    console.error('fatal: could not load config')
    process.exit(1)
  }
}

Choose your catch strategy: catch inside to handle locally and maybe re-throw , or let it propagate to the caller. Don't do both - catching and re-throwing the same error adds noise

top-level await (ES2022)

Before ES2022 , await was only valid inside async functions
Node 14+ supports top-level await in ES modules (.mjs files or "type": "module" in package.json). CommonJS does not support it

// app.mjs - works with Node 16+
import { readFile } from 'fs/promises'

const config = JSON.parse(await readFile('./config.json', 'utf8'))
console.log('config loaded:', config)

export default config

Top-level await blocks the module's execution - other modules that import this one will wait for the top-level await to finish. This is great for loading configuration at startup but be careful: a failed top-level await can prevent the entire application from loading

// database.mjs
import { createConnection } from './db.js'

export const db = await createConnection('postgres://localhost/mydb')

// This module won't finish loading until the DB connection establishes
// If the connection fails , the module load fails
// Any module importing database.mjs will also fail

Security note: Top-level await delays module evaluation. If your module imports sensitive configuration from a remote source (like Vault or AWS Secrets Manager), every module that imports yours waits. This can create initialization order vulnerabilities if not all consumers expect the delay

common pitfalls

Sequential awaits when you need parallel

This is the most common async/await mistake - awaiting independent operations in sequence when they should be parallel

// BAD - sequential , takes 3 seconds
async function loadDashboard(id) {
  const user = await fetchUser(id)       // 1 second
  const posts = await fetchPosts(id)     // 1 second
  const comments = await fetchComments(id) // 1 second
  return { user, posts, comments }
}

// GOOD - parallel , takes ~1 second
async function loadDashboard(id) {
  const [user, posts, comments] = await Promise.all([
    fetchUser(id),
    fetchPosts(id),
    fetchComments(id),
  ])
  return { user, posts, comments }
}

The rule: if operation B doesn't depend on the result of operation A , start them at the same time

Forgetting await

// BAD - returns a Promise , not the data
async function getUser(id) {
  const userPromise = db.findUser(id)  // missing await
  return userPromise
  // getUser returns a Promise<Promise> - not useful
}

// GOOD
async function getUser(id) {
  const user = await db.findUser(id)
  return user
}

In strict mode, your linter should catch this. If it doesn't, configure it to. Forgetting await is the kind of bug that silently returns Promises to places that expect actual values

Loops with await

forEach doesn't work with await

// BAD - forEach does NOT respect await
async function processUsers(ids) {
  ids.forEach(async (id) => {
    const user = await db.findUser(id) // runs in parallel , not sequence
    console.log(user.name)
  })
  // forEach returns immediately - all iterations run concurrently
}

// GOOD - sequential
async function processUsers(ids) {
  for (const id of ids) {
    const user = await db.findUser(id)
    console.log(user.name)
  }
}

// GOOD - parallel map
async function processUsers(ids) {
  const users = await Promise.all(ids.map((id) => db.findUser(id)))
  users.forEach((user) => console.log(user.name))
}

Choose for...of for sequential execution or Promise.all(map) for parallel. Don't use forEach with async functions - it doesn't wait

Return await vs return

// Unnecessary but harmless - more explicit
async function foo() {
  return await bar()
}

// Equivalent - the async wraps bar's Promise automatically
async function foo() {
  return bar()
}

The only difference: return await bar() gives you a better stack trace if bar() rejects because the error includes the foo call site. return bar() skips foo in the stack trace. Modern linters recommend return await inside try/catch blocks for accurate error location

security: uncaught async errors in Express

Express 4 does NOT catch async errors automatically
If your async route handler rejects, Express doesn't call next(err) - it just drops the rejection. Node detects the unhandled rejection and , in modern versions , crashes the process

// This will crash your server
app.get('/user/:id', async (req, res) => {
  const user = await db.findUser(req.params.id)
  // if findUser throws, Express never catches it
  res.json(user)
})

Solutions:

// Option 1: Wrap every handler (tedious but explicit)
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next)

app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await db.findUser(req.params.id)
  res.json(user)
}))

// Option 2: Use express-async-errors (automatic , patches Express)
require('express-async-errors')

app.get('/user/:id', async (req, res) => {
  const user = await db.findUser(req.params.id)
  res.json(user)
})

// Option 3: Upgrade to Express 5 (experimental)
// Express 5 catches async errors natively

For production, option 2 (express-async-errors) is the most common. Just require('express-async-errors') at the top of your entry file and all async errors flow to your error middleware automatically

summary

  • async functions always return a Promise - even if you return a primitive
  • await pauses execution of the async function (not the event loop) until the Promise settles
  • Use try/catch for async error handling - same syntax as synchronous code
  • Use Promise.all() for parallel independent operations , not sequential awaits
  • Top-level await works in ES modules - use it for startup configuration
  • forEach doesn't work with await - use for...of or Promise.all()
  • Express 4 doesn't catch async errors - wrap handlers or use express-async-errors
  • Forgetting await returns a Promise instead of a value - configure your linter

prerequisites

async_02_promises.md - Promises , .then() , .catch()

next -> async_04_error_handling.md