Loading learning content...
For decades, asynchronous code forced a trade-off: you could have performance or readability, but not both. Callbacks created pyramids of doom. Promises improved composability but still required chained .then() calls that obscured logic.
Then came async/await—a language feature that lets you write asynchronous code that looks and reads like synchronous code. No callbacks, no chains—just straightforward, top-to-bottom logic with await marking the async boundaries.
// Before async/await
fetchUser(id)
.then(user => fetchOrders(user.id))
.then(orders => calculateTotal(orders))
.then(total => console.log(total))
.catch(handleError);
// With async/await
const user = await fetchUser(id);
const orders = await fetchOrders(user.id);
const total = await calculateTotal(orders);
console.log(total);
This isn't just syntactic sugar—it's a fundamental improvement in how we reason about async code.
By the end of this page, you will understand how async/await works under the hood, common patterns for sequential and parallel execution, error handling with try/catch, advanced patterns like cancellation and retry, and best practices for production async code.
async/await is syntactic sugar over Promises (and in some languages, over other async primitives). Understanding this relationship is crucial for mastery.
The async keyword:
When you mark a function as async, three things happen:
await inside the functionasync function greet() {
return 'Hello'; // Automatically wrapped
}
greet().then(msg => console.log(msg)); // 'Hello'
// Equivalent to:
function greet() {
return Promise.resolve('Hello');
}
The await keyword:
await does several things:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// ========================================// async function always returns a Promise// ======================================== async function getValue() { return 42;}// Returns: Promise<42> async function getNothing() { // implicit return undefined}// Returns: Promise<undefined> async function throwError() { throw new Error('Oops');}// Returns: rejected Promise with Error // ========================================// await unwraps Promise values// ======================================== async function demo() { // await pauses HERE until Promise resolves const data = await fetchData(); // data is the RESOLVED value, not the Promise console.log(data); // Not: Promise { <pending> } // await on non-Promise values works too const x = await 42; // Just returns 42 immediately // await on already-resolved Promise is immediate const resolved = Promise.resolve('done'); const value = await resolved; // 'done', no real delay} // ========================================// What await actually does (desugared)// ======================================== // This async function:async function fetchUserData(userId) { const user = await fetchUser(userId); const orders = await fetchOrders(user.id); return { user, orders };} // Is conceptually equivalent to:function fetchUserData(userId) { return fetchUser(userId) .then(user => { return fetchOrders(user.id) .then(orders => { return { user, orders }; }); });} // Or flattened with Promise chaining:function fetchUserData(userId) { let user; return fetchUser(userId) .then(u => { user = u; return fetchOrders(u.id); }) .then(orders => ({ user, orders }));} // ========================================// await can only be used inside async// ======================================== function regularFunction() { // const data = await fetchData(); // SyntaxError!} // Solution 1: Make function asyncasync function asyncFunction() { const data = await fetchData(); // OK} // Solution 2: Top-level await (ES2022, modules only)// At module top level:const config = await loadConfig(); // OK in ES modules // Solution 3: Immediately-invoked async expression(async () => { const data = await fetchData(); console.log(data);})();What happens when you await:
awaitPromise.resolve()await returns the valueawait locationThe suspension is key: While one async function is awaiting, the event loop is free. Other callbacks, timers, and I/O handlers can run. This is how async/await achieves concurrency without blocking.
Every async function returns a Promise. Every await consumes a Promise. You can mix async/await with .then()/.catch() freely. Understanding Promises is essential for understanding async/await—they're the same mechanism with different syntax.
One of the most common mistakes with async/await is accidentally serializing operations that could run in parallel. The placement of await determines execution order.
Sequential execution (slower):
Each await waits for the previous to complete before starting the next.
// These run ONE AT A TIME
const user = await fetchUser(id); // 100ms
const orders = await fetchOrders(id); // 100ms
const products = await fetchProducts(id); // 100ms
// Total: 300ms
Parallel execution (faster):
Start all operations first, then await all results.
// All start SIMULTANEOUSLY
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const productsPromise = fetchProducts(id);
// Now wait for all
const user = await userPromise; // ┐
const orders = await ordersPromise; // ├─ 100ms total
const products = await productsPromise; // ┘
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// ========================================// Pattern 1: Sequential (when order matters)// ======================================== async function processInSequence(items) { const results = []; for (const item of items) { // Each iteration waits for the previous const result = await processItem(item); results.push(result); } return results;} // Use when: Each operation depends on the previous// Example: Paginated API where page N needs token from page N-1 // ========================================// Pattern 2: Parallel with Promise.all// ======================================== async function processInParallel(items) { // All start immediately const promises = items.map(item => processItem(item)); // Wait for all to complete const results = await Promise.all(promises); return results;} // Use when: Operations are independent// Example: Fetching multiple independent resources // ========================================// Pattern 3: Parallel with destructuring// ======================================== async function loadDashboard(userId) { // Start all fetches immediately const [user, orders, notifications, settings] = await Promise.all([ fetchUser(userId), fetchOrders(userId), fetchNotifications(userId), fetchSettings(userId), ]); // All are available after Promise.all resolves return { user, orders, notifications, settings };} // ========================================// Pattern 4: Controlled concurrency// ======================================== async function processWithLimit(items, concurrency) { const results = []; const executing = new Set(); for (const item of items) { // Start the promise const promise = (async () => { const result = await processItem(item); executing.delete(promise); return result; })(); executing.add(promise); results.push(promise); // If at concurrency limit, wait for one to finish if (executing.size >= concurrency) { await Promise.race(executing); } } // Wait for remaining return Promise.all(results);} // Usage: Process 100 items, max 5 concurrentawait processWithLimit(items, 5); // ========================================// Common mistake: Unintentional sequential// ======================================== // SLOW: forEach with async doesn't wait properlyasync function slowProcess(items) { items.forEach(async item => { await processItem(item); // Fires immediately, no waiting! }); // Returns HERE before any processing completes!} // The problem: forEach doesn't await the callbacks// Solution: Use for...of for sequential or Promise.all for parallel| Scenario | Approach | Pattern |
|---|---|---|
| Independent operations | Parallel | Promise.all([...]) |
| Operations with dependencies | Sequential | for...of with await |
| Rate-limited API calls | Controlled concurrency | Custom pool pattern |
| Some dependencies, some independent | Mixed | Group parallel, chain sequential |
| Must process in order | Sequential | for...of with await |
Parallel isn't always better. If operations share resources (database connections, API rate limits), parallel execution may cause contention or errors. Use controlled concurrency when interacting with constrained resources.
async/await transforms Promise rejections into thrown exceptions. This enables using traditional try/catch/finally syntax for async error handling—a significant ergonomic improvement over .catch() chains.
The basic pattern:
async function fetchData() {
try {
const response = await fetch(url);
const data = await response.json();
return data;
} catch (error) {
// Catches: network errors, JSON parse errors, etc.
console.error('Failed:', error);
throw error; // Re-throw to propagate
} finally {
// Cleanup: runs whether success or failure
cleanup();
}
}
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
// ========================================// Basic try/catch pattern// ======================================== async function getUserData(userId) { try { const user = await fetchUser(userId); const orders = await fetchOrders(user.id); return { user, orders }; } catch (error) { // Handle different error types if (error instanceof NetworkError) { console.error('Network issue:', error.message); return getCachedData(userId); // Fallback } if (error instanceof NotFoundError) { return null; // User doesn't exist, OK } throw error; // Unknown error, propagate }} // ========================================// Granular error handling// ======================================== async function processOrder(orderId) { let order; let payment; // Handle each await separately if needed try { order = await fetchOrder(orderId); } catch (error) { throw new OrderFetchError(orderId, error); } try { payment = await chargePayment(order); } catch (error) { // Specific recovery for payment failure await refundPartial(order); throw new PaymentError(orderId, error); } try { await sendConfirmation(order, payment); } catch (error) { // Non-critical: log but don't fail console.warn('Email failed:', error); } return { order, payment };} // ========================================// Using .catch() for individual operations// ======================================== async function fetchWithFallback(primary, secondary) { // Try primary, fall back to secondary const data = await fetch(primary) .then(r => r.json()) .catch(() => fetch(secondary).then(r => r.json())); return data;} // ========================================// Error handling with Promise.all// ======================================== async function fetchMultiple(urls) { try { // Promise.all rejects if ANY promise rejects // Other pending promises are NOT cancelled const results = await Promise.all( urls.map(url => fetch(url)) ); return results; } catch (error) { // Only get the FIRST error console.error('At least one fetch failed:', error); throw error; }} // If you need ALL results (success or failure):async function fetchMultipleSafe(urls) { const results = await Promise.allSettled( urls.map(url => fetch(url)) ); const successes = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const failures = results .filter(r => r.status === 'rejected') .map(r => r.reason); if (failures.length > 0) { console.warn(`${failures.length} requests failed`); } return successes;} // ========================================// finally for cleanup// ======================================== async function withConnection(fn) { const connection = await openConnection(); try { return await fn(connection); } catch (error) { console.error('Operation failed:', error); throw error; } finally { // ALWAYS runs - success, error, or return await connection.close(); }} // Usageconst results = await withConnection(async (conn) => { return await conn.query('SELECT * FROM users');});Error handling best practices:
Catch at the right level: Handle errors where you can do something about them. Don't catch just to re-throw.
Use typed errors: Create specific error classes to enable targeted handling.
Always have a top-level catch: Uncaught async errors crash Node.js or cause unhandled rejection warnings.
Consider Promise.allSettled: When you need all results regardless of individual failures.
finally for cleanup: Connections, file handles, locks—use finally to ensure release.
Don't swallow errors: If you catch and don't re-throw, log meaningfully. Silent failures are debugging nightmares.
Consider 'unhappy path first' error handling: check for and handle errors at the top of try blocks, then proceed with success logic. This keeps the main logic unindented and clear, like guard clauses in sync code.
Beyond basic async/await, several patterns address common real-world challenges: retries, timeouts, cancellation, and resource management.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// ========================================// Retry with exponential backoff// ======================================== async function withRetry(fn, options = {}) { const { maxAttempts = 3, baseDelay = 1000, maxDelay = 30000, shouldRetry = () => true, } = options; let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error; // Check if error is retryable if (!shouldRetry(error) || attempt === maxAttempts) { throw error; } // Exponential backoff with jitter const delay = Math.min( baseDelay * Math.pow(2, attempt - 1), maxDelay ); const jitter = delay * 0.2 * Math.random(); console.log(`Attempt ${attempt} failed, retrying in ${delay}ms`); await sleep(delay + jitter); } } throw lastError;} // Usageconst data = await withRetry( () => fetchFromUnreliableAPI(), { maxAttempts: 5, baseDelay: 1000, shouldRetry: (err) => err.status >= 500, // Only retry server errors }); // ========================================// Retry with circuit breaker// ======================================== class CircuitBreaker { constructor(threshold = 5, timeout = 30000) { this.failures = 0; this.threshold = threshold; this.timeout = timeout; this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN this.nextAttempt = 0; } async execute(fn) { if (this.state === 'OPEN') { if (Date.now() < this.nextAttempt) { throw new Error('Circuit breaker is OPEN'); } this.state = 'HALF_OPEN'; } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } onSuccess() { this.failures = 0; this.state = 'CLOSED'; } onFailure() { this.failures++; if (this.failures >= this.threshold) { this.state = 'OPEN'; this.nextAttempt = Date.now() + this.timeout; } }}Unlike killing threads, async cancellation is cooperative—the code must check for cancellation. AbortController provides a standard mechanism, but your code must honor the signal at appropriate checkpoints.
When dealing with streams of async data—paginated APIs, real-time feeds, file streams—async iterators provide elegant solutions. They extend the iterator protocol to work with Promises.
Regular iterators:
for (const item of syncCollection) { ... }
Async iterators:
for await (const item of asyncStream) { ... }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
// ========================================// Async generator function// ======================================== async function* paginate(url) { let page = 1; let hasMore = true; while (hasMore) { const response = await fetch(`${url}?page=${page}`); const data = await response.json(); // Yield each item from this page for (const item of data.items) { yield item; } hasMore = data.hasNextPage; page++; }} // Usage with for-await-ofasync function processAllPages() { for await (const item of paginate('/api/items')) { console.log('Processing:', item); // Process each item as it arrives }} // ========================================// Creating async iterables from events// ======================================== async function* fromEvents(emitter, eventName) { const queue = []; let resolver = null; emitter.on(eventName, (data) => { if (resolver) { resolver(data); resolver = null; } else { queue.push(data); } }); while (true) { if (queue.length > 0) { yield queue.shift(); } else { yield await new Promise(resolve => { resolver = resolve; }); } }} // Usagefor await (const event of fromEvents(socket, 'message')) { console.log('Received:', event);} // ========================================// Stream processing with async iterators// ======================================== async function* map(asyncIterable, fn) { for await (const item of asyncIterable) { yield await fn(item); }} async function* filter(asyncIterable, predicate) { for await (const item of asyncIterable) { if (await predicate(item)) { yield item; } }} async function* take(asyncIterable, n) { let count = 0; for await (const item of asyncIterable) { yield item; if (++count >= n) break; }} // Composing async stream operationsasync function getActiveUsers() { const allUsers = paginate('/api/users'); const activeOnly = filter(allUsers, u => u.active); const withDetails = map(activeOnly, u => fetchDetails(u.id)); const first10 = take(withDetails, 10); const results = []; for await (const user of first10) { results.push(user); } return results;} // ========================================// Node.js readable streams as async iterables// ======================================== const fs = require('fs');const readline = require('readline'); async function processLines(filename) { const fileStream = fs.createReadStream(filename); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); // readline interface is an async iterable! for await (const line of rl) { await processLine(line); }} // ========================================// Async iterator helpers (upcoming ES feature)// ======================================== // Array-like methods for async iterators (proposal)const results = await AsyncIterator.from(paginate('/api')) .filter(x => x.active) .map(x => transform(x)) .take(100) .toArray();When to use async iterators:
| Use Case | Why Async Iterators |
|---|---|
| Paginated APIs | Process items as pages load, not after all |
| File streams | Handle lines/chunks without loading entire file |
| WebSocket messages | Process messages as they arrive |
| Database cursors | Iterate results without loading all into memory |
| Event streams | Transform events with sync-looking code |
Async iterators are lazy—they fetch/process items one at a time. This is crucial for large datasets. Instead of fetching 10,000 items into an array, you process each as it arrives, keeping memory usage constant.
async/await has been adopted by virtually every major language. While the syntax is similar, implementations differ in important ways.
| Language | async keyword | await keyword | Return Type | Notes |
|---|---|---|---|---|
| JavaScript | async function | await | Promise<T> | Single-threaded, event loop |
| Python | async def | await | Coroutine | asyncio event loop |
| C# | async Task | await | Task<T> | State machine compilation |
| Rust | async fn | .await | impl Future | Zero-cost, lazy futures |
| Kotlin | suspend | (implicit) | Any type | Coroutines, structured |
| Swift | async | await | Any type | Actor isolation available |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ========================================// JavaScript// ========================================async function fetchUser(id) { const response = await fetch(`/api/users/${id}`); return await response.json();} // ========================================// Python// ========================================async def fetch_user(user_id: int) -> dict: async with aiohttp.ClientSession() as session: async with session.get(f'/api/users/{user_id}') as response: return await response.json() // ========================================// C#// ========================================public async Task<User> FetchUserAsync(int id){ var response = await _httpClient.GetAsync($"/api/users/{id}"); return await response.Content.ReadAsAsync<User>();} // ========================================// Rust// ========================================async fn fetch_user(id: u32) -> Result<User, Error> { let response = reqwest::get(&format!("/api/users/{}", id)).await?; let user: User = response.json().await?; Ok(user)} // ========================================// Kotlin (Coroutines)// ========================================suspend fun fetchUser(id: Int): User { return httpClient.get("/api/users/$id")} // ======================================== // Swift// ========================================func fetchUser(id: Int) async throws -> User { let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(User.self, from: data)}Key differences to note:
JavaScript/Python: Single-threaded event loop. await releases to the event loop, allowing other tasks to run. True parallelism requires separate mechanisms (Web Workers, multiprocessing).
C#: Compiled to state machines. await may resume on a different thread (unless ConfigureAwait(false)). Task Parallel Library provides true parallelism.
Rust: Futures are lazy—nothing happens until polled by an executor. Zero-cost abstractions mean no runtime overhead. Ownership rules apply to async boundaries.
Kotlin: Structured concurrency with coroutine scopes. Cancellation propagates through the hierarchy. suspend functions can only be called from other suspend functions or coroutine builders.
Swift: Actors provide thread-safe isolation. Async sequences for streaming. Structured concurrency with task groups.
Don't assume async/await works identically across languages. Threading models, cancellation, and execution contexts vary significantly. Always understand the specific language's async runtime before writing production code.
Writing correct and maintainable async code requires discipline. These best practices are distilled from production experience across industries.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
// ========================================// Anti-patterns and corrections// ======================================== // ❌ WRONG: Ignoring errorsasync function bad1() { fetch('/api/data'); // Promise ignored!} // ✅ CORRECT: Handle or returnasync function good1() { const data = await fetch('/api/data'); return data;} // ❌ WRONG: Sequential when parallel is possibleasync function bad2(ids) { const results = []; for (const id of ids) { results.push(await fetch(`/api/${id}`)); // One at a time! } return results;} // ✅ CORRECT: Parallel fetchasync function good2(ids) { return Promise.all( ids.map(id => fetch(`/api/${id}`)) );} // ❌ WRONG: No timeoutasync function bad3() { return await fetch(unreliableUrl); // May hang forever} // ✅ CORRECT: With timeoutasync function good3() { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { return await fetch(unreliableUrl, { signal: controller.signal }); } finally { clearTimeout(timeoutId); }} // ❌ WRONG: async/await in forEachasync function bad4(items) { items.forEach(async item => { await processItem(item); // Doesn't wait! }); console.log('Done'); // Logs BEFORE processing finishes!} // ✅ CORRECT: for...of or Promise.allasync function good4(items) { for (const item of items) { await processItem(item); } console.log('Done'); // After all items} // ❌ WRONG: Re-throwing without contextasync function bad5() { try { return await riskyOperation(); } catch (error) { throw error; // Lost context! }} // ✅ CORRECT: Add context when re-throwingasync function good5() { try { return await riskyOperation(); } catch (error) { throw new Error(`Operation failed in good5: ${error.message}`, { cause: error }); }}Use ESLint rules like no-floating-promises, require-await, no-async-promise-executor, and no-return-await. TypeScript's strict mode catches many async mistakes at compile time. Let tools enforce best practices.
async/await represents the culmination of decades of async programming evolution—from callbacks to promises to synchronized-looking async code. Understanding both the syntax and the underlying mechanisms is essential for modern development.
Module Complete:
You've now completed the full journey through asynchronous programming:
This foundation enables you to write efficient, scalable concurrent code across any platform or language that supports async programming.
Congratulations! You've mastered asynchronous programming from OS primitives to high-level language features. You understand not just how to use async/await, but why it exists and how it works under the hood—knowledge that will serve you across every async challenge you encounter.