Back to Blogs

JavaScript · Asynchronous programming

JavaScript Promises, Explained

A promise is JavaScript's way of representing work that has not finished yet. Once you understand its states, handlers, and composition tools, asynchronous code becomes much easier to read and reason about.

1. First, what does asynchronous mean?

JavaScript usually runs your code on one main thread. It executes one piece of JavaScript at a time, in order. Some operations, however, take an unpredictable amount of time: a network request, a timer, reading a file, or asking a database for data.

If JavaScript waited for each of those operations to finish before doing anything else, a slow request could freeze an entire page. Instead, the runtime starts the operation and lets other work continue. Later, when the result is ready, JavaScript runs the function that handles it.

console.log("1. Start");

setTimeout(() => {
    console.log("3. Timer finished");
}, 0);

console.log("2. Keep going");

// Output:
// 1. Start
// 2. Keep going
// 3. Timer finished

The timer callback runs later, even with a delay of zero. Network and file operations follow the same broad idea: they complete outside the immediate sequence of synchronous statements, then notify your code.

2. Why do Promises exist?

Before Promises became a standard way to handle asynchronous results, JavaScript commonly used callbacks: functions passed to another function to be called when work completed.

getUser(userId, function (error, user) {
    if (error) {
        showError(error);
        return;
    }

    getOrders(user.id, function (error, orders) {
        if (error) {
            showError(error);
            return;
        }

        showOrders(orders);
    });
});

A callback is useful for one operation, but chaining many dependent operations nests code and repeats error handling. This shape is sometimes called callback hell. It is not that callbacks are inherently bad; rather, callbacks alone do not provide a consistent, composable representation of a future result.

A Promise gives that future result a standard object. The object can be returned from a function, passed around, chained with other work, and handled with a common success and failure path. It makes dependencies between asynchronous steps explicit without deeply nested callbacks.

3. The three Promise states

A Promise is always in one of three states:

  • Pending: the operation has not finished yet.
  • Fulfilled: the operation succeeded and produced a value.
  • Rejected: the operation failed and produced a reason, usually an error.

A Promise starts pending and settles exactly once: either fulfilled or rejected. Once settled, its state and result cannot be changed. A fulfilled or rejected Promise is often described as settled.

4. Creating and using a Promise

Most of the time, you will consume Promises returned by APIs such as fetch(). You can also create one with the Promise constructor when you need to adapt a callback-based API or represent a custom asynchronous operation.

const wait = (milliseconds) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("The wait is over");
        }, milliseconds);
    });
};

wait(500).then((message) => {
    console.log(message);
});

The constructor receives an executor function with two functions: resolve and reject. Call resolve(value) when the work succeeds, or reject(error) when it fails. The executor itself runs immediately; here the timer is what schedules the later completion.

Handle success with .then()

Calling .then(onFulfilled) registers a handler for a fulfilled Promise. The handler receives the fulfilled value. It does not run inline at the exact point where the Promise settles; Promise handlers are scheduled as microtasks, after the current synchronous JavaScript has finished.

const ready = Promise.resolve("Ready");

ready.then((value) => console.log(value));
console.log("Synchronous code runs first");

// Output:
// Synchronous code runs first
// Ready

Handle failure with .catch()

Use .catch(onRejected) to handle a rejection. It is shorthand for .then(undefined, onRejected).

const loadSettings = () => {
    return Promise.reject(new Error("Settings are unavailable"));
};

loadSettings()
    .then((settings) => renderSettings(settings))
    .catch((error) => {
        console.error("Could not load settings:", error.message);
        showSettingsError();
    });

Chain work by returning values

Every call to .then() returns a new Promise. If its handler returns a regular value, the next handler receives that value. If it returns another Promise, the chain waits for that Promise to settle. This is what makes a sequence of asynchronous steps compose cleanly.

getUser(userId)
    .then((user) => getOrders(user.id))
    .then((orders) => renderOrders(orders))
    .catch((error) => showError(error));

Throwing inside a handler rejects the Promise returned by that handler, so the error flows to the nearest later .catch(). Returning a value from .catch() recovers the chain; throwing again keeps it rejected.

5. Async and await: a clearer way to write chains

An async function always returns a Promise. If it returns a normal value, that value becomes the Promise's fulfillment value. If it throws, the returned Promise rejects. Inside an async function, await pauses that function until a Promise settles, then gives you its value or throws its rejection reason.

async function showUserOrders(userId) {
    try {
        const user = await getUser(userId);
        const orders = await getOrders(user.id);
        renderOrders(orders);
    } catch (error) {
        showError(error);
    }
}

This reads much like synchronous code, but await does not block the browser's main thread. It suspends only the current async function; other JavaScript can continue to run.

Use try/catch for errors from awaited operations. If you omit the catch, the async function's returned Promise rejects, so its caller must handle that rejection.

6. Real-world Promise patterns

Fetch data from an API

fetch() returns a Promise that fulfills with a Response. One important detail: HTTP error statuses such as 404 or 500 do not usually reject the fetch Promise. Check response.ok and throw when the status is not successful.

async function getProducts() {
    const response = await fetch("/api/products");

    if (!response.ok) {
        throw new Error(`Request failed: ${response.status}`);
    }

    return response.json();
}

getProducts()
    .then((products) => renderProducts(products))
    .catch((error) => showError(error));

Run independent requests in parallel

If two operations do not depend on each other, start both before waiting. Promise.all() fulfills when all inputs fulfill, preserving their input order in the result array. It rejects as soon as one input rejects.

async function loadDashboard() {
    const [profile, notifications] = await Promise.all([
        fetchJson("/api/profile"),
        fetchJson("/api/notifications"),
    ]);

    renderDashboard(profile, notifications);
}

By contrast, awaiting each request before starting the next makes independent work sequential:

// Sequential: the second request starts after the first finishes.
const profile = await fetchJson("/api/profile");
const notifications = await fetchJson("/api/notifications");

// Parallel: both requests start before either is awaited.
const [profile, notifications] = await Promise.all([
    fetchJson("/api/profile"),
    fetchJson("/api/notifications"),
]);

Collect every outcome with allSettled

Sometimes each result is useful even if another operation fails. Promise.allSettled() waits for every input and returns a status and value or reason for each one.

const results = await Promise.allSettled([
    fetchJson("/api/recommendations"),
    fetchJson("/api/recently-viewed"),
]);

for (const result of results) {
    if (result.status === "fulfilled") {
        displayWidget(result.value);
    } else {
        console.warn("A dashboard widget could not load:", result.reason);
    }
}

This is useful for optional dashboard panels, batch tasks, and independent uploads where you want to report each success or failure instead of stopping at the first rejection.

Race for the first settled result

Promise.race() settles with the first input Promise that settles, whether it fulfills or rejects. It can implement a timeout, but a race alone does not cancel the slower operation.

function withTimeout(promise, milliseconds) {
    let timer;

    const timeout = new Promise((_, reject) => {
        timer = setTimeout(() => {
            reject(new Error("Operation timed out"));
        }, milliseconds);
    });

    return Promise.race([promise, timeout]).finally(() => {
        clearTimeout(timer);
    });
}

const result = await withTimeout(fetch("/api/report"), 5000);

If the underlying work should actually stop, use an API that supports cancellation (for example, AbortController with fetch) and abort it when the timeout expires.

Convert a callback API

A Promise can wrap an older callback-based interface, allowing the rest of the application to use async/await.

function readConfigAsPromise(readConfig) {
    return new Promise((resolve, reject) => {
        readConfig((error, config) => {
            if (error) {
                reject(error);
                return;
            }

            resolve(config);
        });
    });
}

For Node.js callback APIs that follow the standard error-first convention, Node also provides util.promisify().

7. Common mistakes to avoid

Forgetting to return a Promise in a chain

// Incorrect: the next then does not wait for saveUser().
getUser(id).then((user) => {
    saveUser(user);
}).then(() => {
    showSavedMessage();
});

// Correct: return the Promise so the chain waits.
getUser(id).then((user) => {
    return saveUser(user);
}).then(() => {
    showSavedMessage();
});

Starting work that is never awaited or returned

An unhandled rejected Promise can produce a runtime warning and leave the user without feedback. If you start asynchronous work, make sure its rejection is handled, returned to a caller that handles it, or intentionally reported.

Using Promise.all() for independent best-effort work

Promise.all() is right when every result is required. If failures are independent and you need the outcome of each task, choose Promise.allSettled() instead.

Thinking Promise means “background thread”

Promises coordinate asynchronous results; they do not move CPU-heavy JavaScript to another thread. A long-running loop can still block rendering. For expensive computation, consider a Web Worker.

8. The mental model to keep

  • A Promise represents a result that may be available now, later, or not at all.
  • It settles once: fulfilled with a value or rejected with a reason.
  • .then() transforms and chains results; .catch() handles failures.
  • async/await is syntax for working with Promises, not a different asynchronous system.
  • Use Promise.all() for required parallel work and allSettled() when you need every independent outcome.

Once you recognize a Promise as a value that describes future work, APIs and patterns that once felt mysterious become a lot more predictable. Start by following the value through each .then() or await, and make sure every failure has a deliberate place to go.