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¶
asyncfunctions always return a Promise - even if you return a primitiveawaitpauses execution of the async function (not the event loop) until the Promise settles- Use
try/catchfor 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
forEachdoesn't work withawait- usefor...oforPromise.all()- Express 4 doesn't catch async errors - wrap handlers or use
express-async-errors - Forgetting
awaitreturns a Promise instead of a value - configure your linter
prerequisites¶
async_02_promises.md - Promises , .then() , .catch()
next -> async_04_error_handling.md