async/await, introduced in ES2017, is syntactic sugar over Promises that lets you write asynchronous code that reads like synchronous code โ without callbacks or .then chains. An async function always returns a Promise. Inside it, await pauses execution until a Promise settles, then resumes with the resolved value. This dramatically improves readability for complex async logic, error handling with try/catch, and conditional async flows. In this lesson you will master every aspect of async/await โ including parallel execution, error handling patterns, top-level await, and the pitfalls that catch developers off guard.
async / await Mechanics
| Feature | Behaviour |
|---|---|
async function fn() |
Always returns a Promise โ returned value is wrapped |
await expr |
Pauses the async function โ resumes when Promise settles |
await on non-Promise |
Wraps value in Promise.resolve() โ effectively a no-op |
| Throwing inside async | Returned Promise rejects with the thrown value |
try/catch |
Catches rejected Promises from await expressions |
| Top-level await (ES2022) | Available at module top level โ not inside regular scripts |
async/await vs Promise Chains
| Pattern | Promise Chain | async/await |
|---|---|---|
| Sequential | .then(a).then(b) |
await a(); await b(); |
| Error handling | .catch(err => ...) |
try { } catch(err) { } |
| Parallel | Promise.all([a,b]) |
await Promise.all([a(),b()]) |
| Conditional | Nested chains โ complex | if (cond) await a(); else await b(); |
| Loops | Difficult with .then | for...of with await |
| Variable access | Must pass through chain | All vars in same scope |
Common async/await Patterns
| Pattern | Code | When to Use |
|---|---|---|
| Sequential | const a = await fa(); const b = await fb(a); |
B depends on A |
| Parallel | const [a,b] = await Promise.all([fa(), fb()]); |
Independent โ run together |
| Optional | const x = await fa().catch(() => null); |
Continue if operation fails |
| Serial loop | for (const item of items) { await process(item); } |
One at a time โ order matters |
| Parallel loop | await Promise.all(items.map(process)); |
All at once โ order doesn’t matter |
async/await does not make code magically concurrent โ await pauses the async function but the event loop continues processing other tasks. Two sequential await calls run one after the other, each waiting for the previous to complete. For true parallel execution, start both Promises before awaiting either: const [a, b] = await Promise.all([fa(), fb()]).const data = await fetchOptional().catch(() => null). This is much cleaner than a full try/catch block when the failure case is simply “use null.” The returned Promise either resolves with the data or resolves with null โ never rejects.await inside forEach does NOT work as expected. forEach does not await the async callback โ it fires all callbacks and moves on before any complete. Use for...of for sequential async iteration, or Promise.all(arr.map(async item => ...)) for parallel async iteration.Basic Example
// โโ async function basics โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function getUser(id) {
// await pauses here until the Promise resolves
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`); // rejects the returned Promise
}
return response.json(); // wraps in Promise.resolve automatically
}
// Calling an async function returns a Promise
const promise = getUser(1); // Promise<User>
const user = await getUser(1); // User โ inside another async function
// โโ try/catch error handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function loadProfile(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id); // runs after user resolves
return { user, posts };
} catch (err) {
console.error('Failed to load profile:', err.message);
return null; // return fallback โ don't re-throw unless caller needs it
} finally {
hideSpinner(); // always runs โ whether success or failure
}
}
// โโ Sequential vs parallel โ critical difference โโโโโโโโโโโโโโโโโโโโโโโโโโ
// SEQUENTIAL โ total time = A + B + C (each waits for previous)
async function sequential() {
const a = await fetchA(); // wait 300ms
const b = await fetchB(); // then wait 200ms
const c = await fetchC(); // then wait 400ms
return [a, b, c]; // total: ~900ms
}
// PARALLEL โ total time = max(A, B, C) (all start simultaneously)
async function parallel() {
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
return [a, b, c]; // total: ~400ms
}
// โโ Async iteration patterns โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const userIds = [1, 2, 3, 4, 5];
// Serial โ processes one at a time (use when order or rate-limiting matters)
async function processSerial(ids) {
const results = [];
for (const id of ids) {
const user = await fetchUser(id); // awaits each before next
results.push(user);
}
return results;
}
// Parallel โ all requests fire at once
async function processParallel(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}
// Batched โ N at a time (balance throughput vs server load)
async function processBatched(ids, batchSize = 3) {
const results = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(id => fetchUser(id)));
results.push(...batchResults);
}
return results;
}
// โโ forEach DOES NOT await โ common pitfall โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// โ Wrong โ all fetch calls fire but results are not awaited
userIds.forEach(async id => {
const user = await fetchUser(id); // awaited inside forEach โ but forEach ignores it
console.log(user);
});
console.log('This runs BEFORE any user is logged!');
// โ
Correct โ for...of awaits each iteration
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user);
}
// โโ Optional operations with inline catch โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function loadPage(userId) {
const user = await fetchUser(userId);
const notifications = await fetchNotifications(userId).catch(() => []); // optional
const draft = await fetchDraft(userId).catch(() => null); // optional
return { user, notifications, draft };
// Page loads even if notifications or draft endpoint is down
}
// โโ Top-level await (ES modules only) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// main.js โ type="module"
const config = await fetch('/config.json').then(r => r.json());
const app = new App(config);
app.start();
How It Works
Step 1 โ async Functions Always Return a Promise
An async function wraps its return value in Promise.resolve(). Returning 42 is equivalent to returning Promise.resolve(42). Throwing an error is equivalent to returning Promise.reject(error). The caller always receives a Promise, whether they use .then() on it or await it inside another async function.
Step 2 โ await Suspends the Function, Not the Thread
When an async function hits await expr, it suspends and yields control back to the event loop โ other code can run while the awaited Promise is pending. When the Promise settles, the function is resumed from the exact point it was suspended, with the resolved value assigned to the variable. Other async functions, event handlers, and timers can run during the suspension.
Step 3 โ Errors Propagate Like Synchronous Exceptions
Inside an async function, a rejected await throws โ exactly as if a synchronous throw statement executed at that point. A single try/catch block can catch errors from multiple await expressions. The finally block runs regardless, making it perfect for cleanup like hiding loading spinners.
Step 4 โ Start Promises Before Awaiting for Parallelism
The key insight for parallel async code: call the async functions to get their Promises, then await both. const p1 = fetchA(); const p2 = fetchB(); const [a, b] = await Promise.all([p1, p2]) starts both requests immediately. Awaiting sequentially โ const a = await fetchA(); const b = await fetchB() โ is serial.
Step 5 โ for…of with await Is Truly Sequential
for (const item of arr) { await process(item) } genuinely awaits each item before moving to the next. This is the correct pattern when operations must be ordered โ for example, running database migrations in sequence, or processing events in the order they arrived. Use Promise.all(arr.map(async item => ...)) when order does not matter and you want maximum throughput.
Real-World Example: API Client with async/await
// api-client.js
class APIClient {
#baseURL;
#headers;
#interceptors = { request: [], response: [] };
constructor(baseURL, defaultHeaders = {}) {
this.#baseURL = baseURL.replace(/\/$/, '');
this.#headers = { 'Content-Type': 'application/json', ...defaultHeaders };
}
use(type, fn) {
this.#interceptors[type].push(fn);
return this;
}
async #request(method, path, { body, params, signal } = {}) {
let url = `${this.#baseURL}${path}`;
if (params) url += '?' + new URLSearchParams(params);
let config = {
method,
headers: { ...this.#headers },
signal,
...(body ? { body: JSON.stringify(body) } : {}),
};
// Run request interceptors (e.g. add auth token)
for (const fn of this.#interceptors.request) {
config = await fn(config);
}
const response = await fetch(url, config);
let data = null;
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
data = await response.json();
}
// Run response interceptors
for (const fn of this.#interceptors.response) {
await fn(response, data);
}
if (!response.ok) {
const error = new Error(data?.message ?? `HTTP ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;
}
get(path, options) { return this.#request('GET', path, options); }
post(path, body, options) { return this.#request('POST', path, { ...options, body }); }
put(path, body, options) { return this.#request('PUT', path, { ...options, body }); }
patch(path, body, options) { return this.#request('PATCH', path, { ...options, body }); }
delete(path, options) { return this.#request('DELETE', path, options); }
}
// Usage
const api = new APIClient('https://api.example.com');
api.use('request', async config => ({
...config,
headers: { ...config.headers, Authorization: `Bearer ${getToken()}` },
}));
api.use('response', async (res, data) => {
if (res.status === 401) await refreshToken();
});
// Clean async/await usage
async function loadUserDashboard(userId) {
try {
const [user, posts, stats] = await Promise.all([
api.get(`/users/${userId}`),
api.get('/posts', { params: { author: userId, limit: 10 } }),
api.get(`/users/${userId}/stats`),
]);
return { user, posts, stats };
} catch (err) {
if (err.status === 404) return null;
throw err;
}
}
Common Mistakes
Mistake 1 โ await inside forEach
โ Wrong โ forEach does not await callbacks:
items.forEach(async item => {
await processItem(item); // awaited inside callback โ but forEach ignores it
});
// All items "processed" before any actually completes
โ Correct โ for…of or Promise.all:
for (const item of items) { await processItem(item); } // serial
await Promise.all(items.map(item => processItem(item))); // parallel
Mistake 2 โ Accidentally sequential when parallel is faster
โ Wrong โ 900ms total when 400ms is possible:
const user = await fetchUser(); // 300ms
const posts = await fetchPosts(); // 200ms (waits for user unnecessarily)
const likes = await fetchLikes(); // 400ms (waits for posts unnecessarily)
โ Correct โ parallel when independent:
const [user, posts, likes] = await Promise.all([fetchUser(), fetchPosts(), fetchLikes()]);
Mistake 3 โ Unhandled async errors crashing silently
โ Wrong โ async IIFE with no error handling:
(async () => {
const data = await fetchData(); // if this rejects โ unhandled rejection warning
render(data);
})();
โ Correct โ always handle errors on the outermost async call:
(async () => {
try {
const data = await fetchData();
render(data);
} catch (err) {
showError(err.message);
}
})();
Quick Reference
| Pattern | Code |
|---|---|
| Async function | async function fn() { return await expr; } |
| Error handling | try { await x } catch(e) { } finally { } |
| Sequential | const a = await fa(); const b = await fb(a); |
| Parallel | const [a,b] = await Promise.all([fa(), fb()]); |
| Serial loop | for (const x of arr) { await process(x); } |
| Parallel loop | await Promise.all(arr.map(async x => process(x))) |
| Optional op | const x = await fa().catch(() => null); |
| Timeout | await Promise.race([fetch(url), rejectAfter(5000)]) |