Technology  /  JavaScript

๐ŸŸจ JavaScript 15 guides ยท updated 2026

Closures, the event loop, promises, and modern ES syntax โ€” the language of the web explained the way it actually behaves at runtime.

JavaScript Promises: States, Chaining, Error Handling, and Promise.all

Before promises, async JavaScript meant callbacks nested in callbacks โ€” โ€œcallback hell,โ€ where error handling had to be reinvented at every level and control flow read like a staircase viewed sideways. Promises (standardized in ES2015) fixed this by making a future result into a first-class value you can hold, pass around, chain, and combine.

Even though async/await now provides nicer syntax for most promise consumption, promises are the machinery underneath โ€” and combinators like Promise.all, error-routing rules, and chaining semantics remain daily tools you use directly. This guide builds the model properly.


What a Promise Is

A promise is an object representing an operation whose result isnโ€™t ready yet. It exists in exactly one of three states:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ fulfilled (has a value)
pending โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ rejected (has a reason/error)

Two rules make promises trustworthy:

  1. Settling is permanent. Once fulfilled or rejected, the state and value never change. Late subscribers get the same answer.
  2. Callbacks always run asynchronously โ€” as microtasks, even if the promise is already settled when you attach .then. No โ€œsometimes sync, sometimes asyncโ€ chaos (a real bug class in pre-promise libraries).

Mostly youโ€™ll receive promises from APIs (fetch, timers wrapped in promises, file APIs, every modern SDK). Creating one from scratch is for wrapping callback-style APIs:

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const readClipboard = () =>
new Promise((resolve, reject) => {
legacyClipboardApi.read((err, text) => {
if (err) reject(err); // reject with the Error
else resolve(text); // fulfill with the value
});
});

The new Promise((resolve, reject) => ...) executor is the bridge between callback-world and promise-world. If you find yourself writing it around something that already returns a promise, stop โ€” thatโ€™s the โ€œexplicit construction antipatternโ€; use the promise you already have.


.then and the Chaining Rules

.then(onFulfilled) registers a callback for the value โ€” and, crucially, returns a new promise, which is what makes chaining work:

fetch("/api/user/42")
.then((res) => res.json()) // returns a promise โ†’ chain waits for it
.then((user) => user.name) // returns a value โ†’ next .then gets it directly
.then((name) => console.log(name));

The rule that makes chains comprehensible โ€” what each .then callback returns determines the next link:

The classic beginner mistake is breaking the chain by forgetting to return:

fetchUser()
.then((user) => { fetchOrders(user.id); }) // BUG: no return โ€”
.then((orders) => render(orders)); // orders is undefined!
fetchUser()
.then((user) => fetchOrders(user.id)) // arrow's implicit return fixes it
.then((orders) => render(orders));

Nothing errors โ€” the second .then just runs with undefined, immediately, without waiting for the orders fetch. When a chain โ€œlosesโ€ a value or races ahead, audit the returns first.


Error Handling: The Rejection Track

A promise chain is two parallel tracks โ€” fulfillment and rejection โ€” and every link routes between them:

fetch("/api/user/42")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); // jump to rejection track
return res.json();
})
.then((user) => render(user))
.catch((err) => showError(err)) // catches ANY failure above it
.finally(() => spinner.hide()); // runs either way; receives nothing

The routing rules:

Which leads to the golden rule: every chain must end in a .catch (or be awaited inside a try/catch, or returned to a caller who does). An unhandled rejection surfaces as the dreaded UnhandledPromiseRejection console error โ€” and in Node, can terminate the process. Fire-and-forget promises with no catch are the async equivalent of swallowed exceptions: failures erased from history.

// the sneaky version โ€” the fetch is "handled" but the HANDLER can fail:
fetchUser().then((u) => render(u)); // render throws? unhandled rejection.
fetchUser().then((u) => render(u)).catch(log); // sealed.

Combinators: Coordinating Multiple Promises

The static methods are where raw promises stay essential even in an async/await world:

Promise.all โ€” all succeed, or fail fast. The workhorse for independent parallel work:

const [user, orders, prefs] = await Promise.all([
fetchUser(id),
fetchOrders(id),
fetchPrefs(id),
]); // total time โ‰ˆ slowest one, not the sum

Resolves with an array of results in input order (not completion order). If any input rejects, all rejects immediately with that first error โ€” right for โ€œI need every pieceโ€ pages, wrong when partial success is useful.

Promise.allSettled โ€” wait for everyone, report per-item. For batch operations where one failure shouldnโ€™t sink the rest:

const results = await Promise.allSettled(urls.map((u) => fetch(u)));
for (const r of results) {
if (r.status === "fulfilled") process(r.value);
else logFailure(r.reason);
}

Never rejects; each entry is {status: "fulfilled", value} or {status: "rejected", reason}.

Promise.race โ€” first to settle wins (success or failure). The classic use is timeouts:

const result = await Promise.race([
fetchData(),
delay(5000).then(() => { throw new Error("timeout"); }),
]);

Promise.any โ€” first to fulfill wins, ignoring rejections unless all reject (mirror-image of all) โ€” think redundant mirrors/endpoints.

The performance habit worth building immediately: spot independent awaits and parallelize them. Sequential fetches of unrelated data is the most common self-inflicted latency in JS applications, and Promise.all is the one-line cure.


A Realistic Composition

Fetch a user, then their orders (dependent โ€” must chain), while separately loading site config (independent โ€” parallel), with fallback and cleanup:

function loadDashboard(userId) {
const configPromise = fetchConfig().catch(() => DEFAULT_CONFIG); // mid-chain recovery
return fetchUser(userId)
.then((user) => Promise.all([user, fetchOrders(user.id), configPromise]))
.then(([user, orders, config]) => buildDashboard(user, orders, config))
.catch((err) => {
metrics.count("dashboard_load_failed");
throw new DashboardError(`Failed for user ${userId}`, { cause: err }); // wrap, keep cause
});
}

Worth noticing: the config fetch starts immediately (promises begin work when created, not when awaited); its private .catch makes it unsinkable; the dependent steps chain; the final catch translates the error preserving the cause โ€” the same error-design principles as any language, in promise dialect. The { cause } option on Error is the standard chaining mechanism.


Promisifying the Past, and Promises as Cache Keys

Two patterns that round out working promise fluency.

Promisification at scale. The readClipboard wrapper above generalizes: any callback API with the Node convention ((err, result) => ...) converts mechanically, and Node ships util.promisify to do it for you. In browsers, the stragglers are event-based APIs โ€” and the wrapper shape is worth knowing because youโ€™ll write it for IndexedDB, image loading, and script loading:

const loadImage = (src) =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load ${src}`));
img.src = src;
});

Once wrapped, the API joins the composable world: Promise.all(srcs.map(loadImage)) preloads a gallery in one line.

The promise-as-cache trick. Because a settled promise happily hands its value to any number of late .thens, caching the promise (not the value) deduplicates concurrent requests elegantly:

const cache = new Map();
function getUser(id) {
if (!cache.has(id)) {
cache.set(id, fetchUser(id).catch((e) => { cache.delete(id); throw e; }));
}
return cache.get(id); // callers 2..N share the SAME in-flight promise
}

Ten components requesting user 42 simultaneously produce one network call โ€” the nine late arrivals attach to the in-flight promise. The .catch-and-delete keeps failures from being cached forever. This pattern (request deduplication) sits inside every data-fetching library youโ€™ll ever use; now you can read it โ€” and itโ€™s a closure + Map construction, stitched with this chapterโ€™s semantics.

Frequently Asked Questions

Do promises make code run in parallel? No โ€” JS is single-threaded; promises are scheduling, not threading. The waiting overlaps (three network calls in flight together โ€” thatโ€™s the Promise.all win), but JS callbacks still run one at a time. True CPU parallelism needs workers.

Can I cancel a promise? Not the promise itself โ€” settling is the only exit. Cancellation is handled at the operation level via AbortController: pass its signal to fetch (or check it in your own code), and abort triggers a rejection. Pattern details in the fetch guide.

Whatโ€™s the difference between .then(f, g) and .then(f).catch(g)? The two-argument formโ€™s g does NOT catch errors thrown inside f โ€” it only handles rejections from upstream. .catch placed after also covers fโ€™s own failures, which is nearly always what you want; prefer it.

Is .finally just sugar? Almost โ€” it runs on either outcome, receives no value, and passes the settlement through untouched (perfect for spinners and cleanup). One subtlety: a throw inside .finally replaces the outcome, like finally blocks in Java โ€” keep it side-effect-only.

Promise.all with an empty array? Resolves immediately with [] โ€” convenient for โ€œmap over possibly-empty list then allโ€ pipelines. And non-promise values in the input array are treated as already-fulfilled โ€” Promise.all([1, fetchX()]) works.

Whatโ€™s Promise.withResolvers()? A recent convenience (ES2024) exposing { promise, resolve, reject } without the executor dance โ€” for the cases where settle-from-outside is genuinely the design (a deferred you resolve from an event handler). Same power, less nesting; same caution: if youโ€™re deferring routinely, a callback API probably wanted promisifying instead.

Do promises leak if never settled? A pending promise nobody references gets garbage-collected like any object โ€” no eternal registry. The leak risk is the other direction: closures inside a never-settling executor keep their captures alive, and awaited-forever code paths hold their whole async function frame. Timeouts on external waits are hygiene, not paranoia.


Micro-Glossary for Reading Promise Discussions

Terms youโ€™ll meet in docs, issues, and interviews, pinned down: settled โ€” fulfilled or rejected (no longer pending); resolved โ€” subtly different: a promise โ€œresolved toโ€ another promise adopts its eventual state, so a resolved promise can still be pending (this is why the spec says resolve, not fulfill); thenable โ€” any object with a .then method, which await and Promise.resolve treat as a promise (the interop hook that let pre-standard promise libraries participate); executor โ€” the (resolve, reject) => {} function passed to new Promise, run synchronously at construction; unhandled rejection โ€” a rejection with no handler attached by the end of the current microtask drain, surfacing as the console warning or Node event. Knowing โ€œresolved โ‰  fulfilledโ€ and โ€œthenableโ€ marks you as someone whoโ€™s read past the tutorials โ€” and both distinctions occasionally matter in real debugging, when a promise chain adopts a slow inner promise or a libraryโ€™s thenable behaves half-standardly.

Self-Check

// 1 โ€” what logs, and in what order?
Promise.resolve("A")
.then((v) => { throw new Error("boom"); })
.then((v) => console.log("then:", v))
.catch((e) => { console.log("catch:", e.message); return "recovered"; })
.then((v) => console.log("after:", v));
// 2 โ€” how long does this take, roughly?
await Promise.all([delay(300), delay(200), delay(100)]);
// 3 โ€” and this?
await delay(300); await delay(200); await delay(100);
// 4 โ€” spot the unhandled rejection risk:
function save(data) {
validate(data);
return api.post("/save", data);
}
save(payload).catch(showError);

Answers: (1) catch: boom, then after: recovered โ€” the throw skips the second .then; the catch recovers onto the fulfillment track. (2) ~300ms โ€” parallel. (3) ~600ms โ€” sequential; the classic waste when steps are independent. (4) if validate throws synchronously, save throws before returning a promise โ€” the .catch never attaches. Fix by making save async (sync throws become rejections) or validating inside the chain. Subtle, real, and a perfect segue to the next guide: async/await, where promises get syntax that makes such seams visible.