async / await

โ–ถ Try It Yourself

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
Note: 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()]).
Tip: For optional async operations that should not abort the main flow if they fail, use the inline catch pattern: 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.
Warning: Using 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);
    }
})();

▶ Try It Yourself

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)])

🧠 Test Yourself

You need to fetch user data from three endpoints. The calls are independent. What is the most efficient pattern?





โ–ถ Try It Yourself