JavaScript Async Programming: Promises, async/await, and the Event Loop Without the Usual Bugs

Key takeaways

JavaScript runs your code on one thread and hands waiting to the event loop. Promises and async/await make that readable, but the common patterns hide real bugs: rejections nobody handles, timeouts that leave requests running, and parallel work that is accidentally serial. This guide covers the model and those fixes.

Introduction

What is asynchronous programming?

Asynchronous code does not block the rest of the program while waiting for a long-running operation. Sync vs async:

console.log("1");
console.log("2");
console.log("3");
console.log("1");
setTimeout(() => console.log("2"), 1000);
console.log("3");

Why async matters:

  • Network: HTTP calls can take hundreds of ms or more
  • Disk: file I/O in Node.js
  • Timers: setTimeout, setInterval
  • User input: clicks, keyboard

The model behind all of it is simple, and worth having in mind before any syntax. JavaScript executes your code on one thread. When you start something slow, such as a network request or a timer, the browser or Node.js does the waiting outside your code and, when it finishes, queues a callback. The event loop runs those callbacks one at a time, each to completion, whenever the call stack is empty. Nothing ever interrupts a running function. That is why there are no data races on ordinary variables, and also why one long synchronous loop freezes the whole page: while it runs, no click, timer, or network response can be handled.

Callbacks, Promises, and async/await are three ways of writing the same thing: “run this later, when the result is ready”. This article is about choosing between them and, more importantly, about the bugs each one makes easy (the Node.js side, including the fs and stream APIs, is covered in Node.js async programming).


Callbacks

Basic callback

function fetchData(callback) {
    setTimeout(() => {
        const data = { id: 1, name: "Alice" };
        callback(data);
    }, 1000);
}
console.log("start");
fetchData(data => {
    console.log("data:", data);
});
console.log("end");

Callback hell

getUser(userId, (user) => {
    getOrders(user.id, (orders) => {
        getOrderDetails(orders[0].id, (details) => {
            console.log(details);
        });
    });
});

The indentation is the visible problem. The deeper problems are error handling and control: every level needs its own error check (Node’s (err, result) convention exists for this reason), an exception thrown inside a callback cannot be caught by a try around the outer call because that call already returned, and nothing prevents a buggy API from calling your callback twice or never. Promises were designed to fix exactly those three things: one error path through a chain, errors that propagate like exceptions, and a guarantee that a promise settles at most once.


Promises

What is a Promise?

A Promise represents the eventual outcome of an async operation.

States:

  • Pending: initial
  • Fulfilled: success
  • Rejected: failure

Creating a Promise

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        const success = true;
        if (success) resolve("ok");
        else reject("fail");
    }, 1000);
});
promise
    .then(result => console.log(result))
    .catch(err => console.error(err))
    .finally(() => console.log("done"));

Chaining

function fetchUser(userId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ id: userId, name: "Alice" });
        }, 1000);
    });
}
function fetchOrders(userId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve([{ id: 1, product: "laptop" }]);
        }, 1000);
    });
}
function fetchOrderDetails(orderId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ id: orderId, price: 1200 });
        }, 1000);
    });
}
fetchUser(1)
    .then(user => {
        console.log("user:", user);
        return fetchOrders(user.id);
    })
    .then(orders => {
        console.log("orders:", orders);
        return fetchOrderDetails(orders[0].id);
    })
    .then(details => {
        console.log("details:", details);
    })
    .catch(err => console.error("error:", err))
    .finally(() => console.log("all steps finished"));

Rule: return the next Promise from then to continue the chain.

promise
    .then(result => {
        return anotherPromise();
    })
    .then(result2 => { });
promise
    .then(result => {
        anotherPromise();
    })
    .then(result2 => {
        // result2 may be undefined if you forgot return
    });

Static methods

Promise.resolve(42).then(console.log);
Promise.reject("err").catch(console.error);
Promise.all([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)])
    .then(console.log);
Promise.all([
    Promise.resolve(1),
    Promise.reject("err"),
    Promise.resolve(3)
]).catch(console.error);
Promise.allSettled([
    Promise.resolve(1),
    Promise.reject("err"),
    Promise.resolve(3)
]).then(console.log);
Promise.race([
    new Promise(r => setTimeout(() => r(1), 1000)),
    new Promise(r => setTimeout(() => r(2), 500)),
]).then(console.log);
Promise.any([
    Promise.reject("e1"),
    new Promise(r => setTimeout(() => r(2), 500)),
]).then(console.log);

Practical snippets:

async function loadDashboard() {
    try {
        const [user, orders, notifications] = await Promise.all([
            fetchUser(),
            fetchOrders(),
            fetchNotifications()
        ]);
        renderDashboard(user, orders, notifications);
    } catch (error) {
        console.error("dashboard load failed:", error);
    }
}
function timeout(ms) {
    return new Promise((_, reject) =>
        setTimeout(() => reject(new Error("timeout")), ms)
    );
}
async function fetchWithTimeout(url, ms = 5000) {
    return Promise.race([fetch(url), timeout(ms)]);
}

Know how each combinator behaves on failure, because that is where they differ. Promise.all rejects as soon as any input rejects, and the other promises keep running: their results are simply ignored, and nothing is cancelled. allSettled never rejects and reports every outcome, which suits “load what you can” pages. race settles with whichever promise settles first, success or failure. any waits for the first success and rejects only if all inputs reject, with an AggregateError whose errors array holds every reason.

The race-based timeout above has a flaw worth understanding: when the timer wins, the fetch is not cancelled. The request keeps running, and its response is downloaded and discarded. On a slow endpoint under load, you pile up abandoned requests. The timer also keeps running when the fetch wins first. Modern browsers and Node.js 18+ support cancellation directly:

async function fetchWithTimeout(url, ms = 5000) {
    const response = await fetch(url, { signal: AbortSignal.timeout(ms) });
    return response.json();   // throws a "TimeoutError" DOMException if time runs out
}

AbortSignal.timeout actually aborts the request, and AbortController gives you a signal you can abort yourself, for example when a user navigates away or types a new search query.


async/await

Basics

async function fetchData() {
    return "data";
}
fetchData().then(console.log);
async function getData() {
    const data = await fetchData();
    console.log(data);
}

Sequential flow

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchUser(userId) {
    await delay(1000);
    return { id: userId, name: "Alice" };
}
async function main() {
    try {
        const user = await fetchUser(1);
        const orders = await fetchOrders(user.id);
        const details = await fetchOrderDetails(orders[0].id);
        console.log(details);
    } catch (error) {
        console.error("error:", error);
    }
}

Parallelism

async function sequential() {
    const u1 = await fetchUser(1);
    const u2 = await fetchUser(2);
    return [u1, u2];
}
async function parallel() {
    const [u1, u2, u3] = await Promise.all([
        fetchUser(1),
        fetchUser(2),
        fetchUser(3)
    ]);
    return [u1, u2, u3];
}
async function parallel2() {
    const p1 = fetchUser(1);
    const p2 = fetchUser(2);
    return [await p1, await p2];   // ⚠️ see below
}

sequential takes the sum of both request times, parallel roughly the time of the slowest. The difference comes only from when the promises are created: a request starts when you call fetchUser, not when you await it. await just pauses until an already-running operation finishes.

parallel2 looks equivalent to parallel but has a real bug. If p2 rejects while the function is still waiting for p1, nothing is listening to p2 at that moment, and the runtime reports an unhandled rejection. In Node.js 15 and later, the default response to an unhandled rejection is to crash the process, even though await p2 would have caught the error a moment later. Browsers log it to the console. Promise.all attaches handlers to every promise immediately, which is why it is the safe way to wait for several operations at once.

This is one of the async bugs I see most in production Node services, because it only triggers when the second request fails faster than the first one succeeds, a timing that tests rarely produce. The fix is always the same: combine concurrent promises with Promise.all or Promise.allSettled, never with a sequence of separate awaits.


Error handling

try/catch with async/await

async function fetchData() {
    try {
        const response = await fetch("https://api.example.com/data");
        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }
        return await response.json();
    } catch (error) {
        console.error(error.message);
        return null;
    } finally {
        console.log("request finished");
    }
}

Promise errors

fetch("https://api.example.com/data")
    .then(response => {
        if (!response.ok) throw new Error("HTTP error");
        return response.json();
    })
    .then(console.log)
    .catch(console.error)
    .finally(() => console.log("done"));

Practical examples

Fetch GitHub user

async function fetchGitHubUser(username) {
    try {
        const response = await fetch(`https://api.github.com/users/${username}`);
        if (!response.ok) {
            throw new Error(`User not found: ${response.status}`);
        }
        const user = await response.json();
        return { name: user.name, bio: user.bio, repos: user.public_repos };
    } catch (error) {
        console.error(error.message);
        return null;
    }
}

Retry with backoff

async function fetchWithRetry(url, maxRetries = 3) {
    for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
            const response = await fetch(url);
            if (response.ok) return await response.json();
            // Retry only errors that may succeed later
            if (response.status < 500 && response.status !== 429) {
                throw new Error(`HTTP ${response.status}`);   // e.g. 404: retrying won't help
            }
        } catch (error) {
            if (error.message.startsWith("HTTP 4")) throw error;
        }
        if (attempt < maxRetries - 1) {
            const backoff = 1000 * 2 ** attempt;
            await new Promise(r => setTimeout(r, backoff * (0.5 + Math.random())));  // jitter
        }
    }
    throw new Error("max retries exceeded");
}

A common version of this helper only retries when fetch throws. But fetch throws only on network failures. An HTTP 500 or 503 resolves normally with response.ok === false, so that version skips the backoff, loops immediately, and finally returns undefined instead of an error. The version above treats server errors and 429 (rate limited) as retryable, fails fast on other 4xx responses, and always ends with an error rather than undefined. The random jitter keeps many clients that failed at the same moment from retrying in lockstep. Retry only requests that are safe to repeat: a retried POST that already succeeded on the server can create a duplicate order.

Timeout wrapper

function timeout(ms) {
    return new Promise((_, reject) => {
        setTimeout(() => reject(new Error("Timeout")), ms);
    });
}
async function fetchWithTimeout(url, ms = 5000) {
    try {
        const response = await Promise.race([fetch(url), timeout(ms)]);
        return await response.json();
    } catch (error) {
        if (error.message === "Timeout") console.error("request timed out");
        throw error;
    }
}

Parallel fetch with partial failure

async function fetchMultipleUsers(userIds) {
    const promises = userIds.map(id => fetchUser(id));
    try {
        return await Promise.all(promises);
    } catch (error) {
        console.error("fetch failed:", error);
        return [];
    }
}
async function fetchMultipleUsersSettled(userIds) {
    const results = await Promise.allSettled(userIds.map(id => fetchUser(id)));
    return results
        .filter(r => r.status === "fulfilled")
        .map(r => r.value);
}

Sequential processing

async function processSequentially(items) {
    let result = 0;
    for (let item of items) {
        result = await processItem(item, result);
    }
    return result;
}
async function processSequentiallyReduce(items) {
    return items.reduce(async (accP, item) => {
        const acc = await accP;
        return processItem(item, acc);
    }, Promise.resolve(0));
}

Event loop

JavaScript runs one thread per realm, but the event loop schedules async work.

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

Rough order:

  1. Sync code: 1, 4
  2. Microtasks (Promises): 3
  3. Macrotasks (setTimeout): 2
setTimeout(() => console.log("macrotask"), 0);
Promise.resolve().then(() => console.log("microtask"));
console.log("sync");

The rule behind that order: after each task (a timer callback, an event handler, the initial script), the engine runs all queued microtasks, including any microtasks those microtasks queue, before it takes the next task or lets the browser render. Promise reactions and await continuations are microtasks, and setTimeout(fn, 0) is a task, which is also clamped to a minimum delay in browsers after a few levels of nesting.

Two practical consequences follow. First, await always yields, even on a value that is already available: code after await 42 runs in a later microtask, not immediately, so an await in a hot loop has a real cost. Second, a chain of microtasks that keeps scheduling more microtasks starves everything else: no rendering, no input, no timers, until the chain ends. If a long computation needs to stay responsive, split it into chunks scheduled as tasks (setTimeout, or scheduler.postTask where available) so the browser gets a chance to paint between them, or move it into a Web Worker.


Patterns

Loading state

class DataFetcher {
    constructor() {
        this.loading = false;
        this.data = null;
        this.error = null;
    }
    async fetch(url) {
        this.loading = true;
        this.error = null;
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            this.data = await response.json();
        } catch (error) {
            this.error = error.message;
        } finally {
            this.loading = false;
        }
        return this.data;
    }
}

Simple cache

class CachedFetcher {
    constructor() {
        this.cache = new Map();   // url -> Promise
    }
    fetch(url) {
        if (!this.cache.has(url)) {
            const p = fetch(url)
                .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
                .catch(err => { this.cache.delete(url); throw err; });  // don't cache failures
            this.cache.set(url, p);
        }
        return this.cache.get(url);
    }
}

Caching the promise rather than the resolved data matters when several callers ask for the same URL at the same time. A cache that stores data only after the response arrives lets every caller that arrives before that moment start its own request, so ten components rendering at once send ten identical requests. Storing the promise immediately means the second caller gets the first caller’s in-flight request. Removing it on failure keeps one bad response from being cached forever. This map never evicts entries, so for long-running pages add a size limit or expiry.

Concurrency queue

class TaskQueue {
    constructor(concurrency = 1) {
        this.concurrency = concurrency;
        this.running = 0;
        this.queue = [];
    }
    async add(task) {
        return new Promise((resolve, reject) => {
            this.queue.push({ task, resolve, reject });
            this.process();
        });
    }
    async process() {
        if (this.running >= this.concurrency || this.queue.length === 0) return;
        this.running++;
        const { task, resolve, reject } = this.queue.shift();
        try {
            resolve(await task());
        } catch (error) {
            reject(error);
        } finally {
            this.running--;
            this.process();
        }
    }
}

Common mistakes

Forgetting await

async function getData() {
    const data = fetchData();
    console.log(data);
}
async function getDataFixed() {
    const data = await fetchData();
    console.log(data);
}

Serial vs parallel

async function slow() {
    const u1 = await fetchUser(1);
    const u2 = await fetchUser(2);
    return [u1, u2];
}
async function fast() {
    return Promise.all([fetchUser(1), fetchUser(2)]);
}

forEach with await

async function bad(items) {
    items.forEach(async item => {
        await processItem(item);
    });
    console.log("done too early");
}
async function goodSequential(items) {
    for (const item of items) await processItem(item);
    console.log("done");
}
async function goodParallel(items) {
    await Promise.all(items.map(processItem));
    console.log("done");
}

Missing try/catch

async function getData() {
    try {
        return await fetchData();
    } catch (error) {
        console.error(error);
        return null;
    }
}

Exercises

Rewrite chain as async/await

async function getDataAsync() {
    try {
        const user = await fetchUser(1);
        const orders = await fetchOrders(user.id);
        return await fetchOrderDetails(orders[0].id);
    } catch (error) {
        console.error(error);
    }
}

Parallel users, sequential orders per user

async function processUsers(userIds) {
    const users = await Promise.all(userIds.map(id => fetchUser(id)));
    const results = [];
    for (const user of users) {
        const orders = await fetchOrders(user.id);
        results.push({ user, orders });
    }
    return results;
}

Promisify a callback API

function readFileCallback(filename, callback) {
    setTimeout(() => {
        callback(null, `contents of ${filename}`);
    }, 1000);
}
function readFilePromise(filename) {
    return new Promise((resolve, reject) => {
        readFileCallback(filename, (error, data) => {
            if (error) reject(error);
            else resolve(data);
        });
    });
}