Mastering Async/Await in JavaScript: From Promises to Modern Patterns
JavaScript's async/await syntax transformed asynchronous programming from a callback nightmare into readable, sequential-looking code. But to use it effectively, you need to understand what's happening underneath.
The Evolution of Async JavaScript
// 1. Callback hell (2010s)
getUser(id, function(err, user) {
if (err) return handleError(err)
getPosts(user.id, function(err, posts) {
if (err) return handleError(err)
getComments(posts[0].id, function(err, comments) {
// 😱 Three levels deep and counting
})
})
})
// 2. Promises (ES6, 2015)
getUser(id)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => console.log(comments))
.catch(handleError)
// 3. Async/Await (ES2017)
try {
const user = await getUser(id)
const posts = await getPosts(user.id)
const comments = await getComments(posts[0].id)
console.log(comments)
} catch (err) {
handleError(err)
}
Async/await reads like synchronous code but runs asynchronously.
How It Works Under the Hood
async/await is syntactic sugar over Promises. Every async function returns a Promise:
async function greet() {
return 'Hello'
}
// Equivalent to:
function greet() {
return Promise.resolve('Hello')
}
// Both work the same:
greet().then(msg => console.log(msg)) // "Hello"
const msg = await greet() // "Hello"
await pauses execution of the async function until the Promise settles, then resumes with the resolved value (or throws if rejected):
async function fetchUser(id) {
// Execution pauses here — the event loop is NOT blocked
const response = await fetch(`/api/users/${id}`)
const user = await response.json()
return user
}
Error Handling
The try/catch pattern is the standard approach:
async function loadData() {
try {
const data = await fetchData()
return process(data)
} catch (err) {
console.error('Fetch failed:', err.message)
return null
} finally {
setLoading(false) // Always runs
}
}
For more granular control, catch at each step:
async function resilientFetch(url) {
const response = await fetch(url).catch(err => {
throw new Error(`Network error: ${err.message}`)
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json().catch(() => {
throw new Error('Invalid JSON response')
})
}
The Sequential vs Parallel Trap
This is the most common async/await mistake — unintentional sequencing:
// ❌ SLOW: runs one after another (sequential)
// Total time = timeA + timeB + timeC
const userA = await fetchUser(1)
const userB = await fetchUser(2)
const userC = await fetchUser(3)
// ✅ FAST: runs simultaneously (parallel)
// Total time = max(timeA, timeB, timeC)
const [userA, userB, userC] = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3),
])
When operations are independent, run them in parallel with Promise.all.
When they depend on each other, sequential await is correct.
Promise Combinators
Promise.all — All must succeed
const [user, posts, settings] = await Promise.all([
getUser(id),
getPosts(id),
getSettings(id),
])
// If ANY rejects, the whole Promise.all rejects
Promise.allSettled — Get all results regardless
const results = await Promise.allSettled([
fetch('/api/primary'),
fetch('/api/fallback'),
])
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value)
} else {
console.log('Failed:', result.reason)
}
})
Promise.race — First to settle wins
// Timeout pattern
async function fetchWithTimeout(url, ms = 5000) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
)
return Promise.race([fetch(url), timeout])
}
Promise.any — First to succeed wins
// Try multiple mirrors, use whichever responds first
const data = await Promise.any([
fetch('https://cdn1.example.com/data.json'),
fetch('https://cdn2.example.com/data.json'),
fetch('https://cdn3.example.com/data.json'),
])
// Rejects only if ALL fail (AggregateError)
Async Iteration
For processing streams or large datasets:
// for-await-of with async iterables
async function processStream(stream) {
for await (const chunk of stream) {
await processChunk(chunk)
}
}
// Async generator
async function* paginate(url) {
let page = 1
while (true) {
const { data, hasMore } = await fetch(`${url}?page=${page}`).then(r => r.json())
yield data
if (!hasMore) break
page++
}
}
// Usage
for await (const batch of paginate('/api/items')) {
await processBatch(batch)
}
Real-World Patterns
Retry with Exponential Backoff
async function retry(fn, { attempts = 3, delay = 1000, backoff = 2 } = {}) {
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (err) {
if (i === attempts - 1) throw err
await new Promise(r => setTimeout(r, delay * backoff ** i))
}
}
}
// Usage
const data = await retry(() => fetch('/api/unstable').then(r => r.json()), {
attempts: 5,
delay: 500,
})
Concurrency Limiting
Run at most N tasks simultaneously:
async function limitedConcurrency(items, fn, limit = 5) {
const results = []
const executing = []
for (const item of items) {
const p = Promise.resolve().then(() => fn(item))
results.push(p)
if (limit <= items.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1))
executing.push(e)
if (executing.length >= limit) await Promise.race(executing)
}
}
return Promise.all(results)
}
// Process 100 items with max 5 concurrent requests
const results = await limitedConcurrency(urls, fetchData, 5)
Memoization with Async
function memoAsync(fn) {
const cache = new Map()
return async function(...args) {
const key = JSON.stringify(args)
if (cache.has(key)) return cache.get(key)
const result = await fn(...args)
cache.set(key, result)
return result
}
}
const cachedFetch = memoAsync(url => fetch(url).then(r => r.json()))
// Second call returns instantly from cache
await cachedFetch('/api/config')
await cachedFetch('/api/config') // Cache hit
Common Pitfalls
1. Forgetting await
// ❌ Bug: comparing a Promise object (always truthy) to a string
if (getStatus() === 'active') { ... }
// ✅ Correct
if (await getStatus() === 'active') { ... }
2. Async in Array Methods
// ❌ forEach doesn't await — all items fire simultaneously
items.forEach(async (item) => {
await processItem(item) // Not awaited!
})
// ✅ Sequential: use for...of
for (const item of items) {
await processItem(item)
}
// ✅ Parallel: use Promise.all with map
await Promise.all(items.map(item => processItem(item)))
3. Unhandled Promise Rejections
// ❌ Floating promise — rejection goes unhandled
fetchData() // No await, no .catch()
// ✅ Handled
await fetchData().catch(console.error)
// Or attach a global handler (last resort)
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason)
})
4. The "Awaiting in a Loop" Pattern
// ❌ Sequential when parallel would be faster
const results = []
for (const id of ids) {
results.push(await fetchItem(id))
}
// ✅ Parallel
const results = await Promise.all(ids.map(id => fetchItem(id)))
Async/Await with TypeScript
interface User { id: number; name: string; email: string }
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<User>
}
// Return type is automatically inferred as Promise<User>
async function getDisplayName(id: number) {
const { name } = await fetchUser(id)
return name
}
Testing Async Code
// Jest / Vitest
test('fetches user successfully', async () => {
const user = await fetchUser(1)
expect(user.name).toBe('Alice')
})
test('handles 404 gracefully', async () => {
await expect(fetchUser(9999)).rejects.toThrow('HTTP 404')
})
Quick Reference
| Pattern | When to use |
|---|---|
await |
Sequential, dependent steps |
Promise.all |
Independent parallel tasks (all must succeed) |
Promise.allSettled |
Parallel tasks where partial failures are OK |
Promise.race |
Timeout, first-available wins |
Promise.any |
Redundant sources, first success wins |
for await...of |
Async iterables, streams |
Async/await is one of JavaScript's most powerful features. Combine it with Promise.all for parallelism and proper error handling patterns, and you'll write server and client code that's both fast and maintainable. For more developer tools, explore MagmaNex — including our JWT Decoder and Base64 Encoder.

