JavaScript Promises and Async/Await Explained
JavaScript Promises represent results that may become available later. async and await do not replace Promises; they provide a clearer way to create and consume them. An async function always returns a Promise, and await pauses only that function until a value is ready. It does not block the JavaScript thread.
That short answer is useful, but it leaves important questions open. What exactly changes when a Promise settles? Why does a .then() handler run later even when its Promise is already fulfilled? What happens to the rest of an async function after await? Why can two visually similar pieces of code have very different performance?
This guide explains what JavaScript is actually doing so you can answer those questions from a mental model instead of memorizing syntax.
Promises depend on the runtime's scheduling rules. If tasks, microtasks, and the call stack are unfamiliar, read The JavaScript Event Loop: Call Stack, Microtasks, Macrotasks, and Everything Between first. This article starts where that one ends: with Promise reactions entering the microtask queue.
The problem Promises solve
A normal function can return a value immediately:
function getUserName() {
return "Maya";
}
const name = getUserName();
console.log(name);A network request cannot usually do that. Its result may arrive in 30 milliseconds, three seconds, or not at all. Returning the final value immediately is impossible because the value does not exist yet.
Older asynchronous APIs commonly accepted callbacks:
readUser(42, (error, user) => {
if (error) {
handleError(error);
return;
}
readOrders(user.id, (error, orders) => {
if (error) {
handleError(error);
return;
}
showOrders(orders);
});
});Callbacks are not inherently wrong. The difficulty appears when many dependent operations need sequencing, error propagation, and composition. Each API controls when and how it calls the next function, while every nesting level must repeat error-handling logic.
A Promise changes the interface. Instead of accepting the rest of the program as a callback, an asynchronous function returns an object immediately:
const userPromise = readUser(42);That object is not the user. It is a stable handle for the eventual outcome of the operation. Other code can attach success and failure handlers, return the Promise, combine it with other Promises, or wait for it with await.
What is a JavaScript Promise?
A JavaScript Promise is an object representing the eventual outcome of an operation. It has one of three states:
| State | Meaning |
|---|---|
pending |
The operation has not produced an outcome. |
fulfilled |
The operation completed with a value. |
rejected |
The operation failed with a rejection reason. |
A fulfilled or rejected Promise is settled. Once settled, its state and outcome cannot change.
const promise = new Promise((resolve, reject) => {
const succeeded = true;
if (succeeded) {
resolve({ id: 42, name: "Maya" });
} else {
reject(new Error("Could not load the user"));
}
});Calling resolve(value) attempts to resolve the Promise with value. Calling reject(reason) rejects it. Later calls have no effect after the Promise has been resolved or rejected.
Resolved and fulfilled are not always the same
Most introductory explanations use “resolved” as a synonym for “fulfilled,” but the language model is more precise. A Promise can be resolved to another still-pending Promise:
let finishRequest;
const request = new Promise((resolve) => {
finishRequest = resolve;
});
const wrapper = new Promise((resolve) => {
resolve(request);
});wrapper is now locked to request. It is resolved, but it remains pending until request settles. If request fulfills, wrapper fulfills with the same value. If request rejects, wrapper rejects with the same reason.
This “adoption” behavior is why returning a Promise from a .then() handler creates a flat chain instead of a nested Promise that must be manually unpacked.
The Promise executor runs synchronously
The function passed to new Promise() is called the executor. JavaScript invokes it immediately while constructing the Promise.
console.log("A");
const promise = new Promise((resolve) => {
console.log("B: executor");
resolve("done");
});
promise.then((value) => {
console.log("D:", value);
});
console.log("C");The output is:
A
B: executor
C
D: doneB appears immediately because the executor is synchronous. D appears later because .then() registers a Promise reaction. Even though the Promise has already fulfilled, JavaScript schedules that reaction as a Job; browser and Node.js runtimes process Promise reaction Jobs through their microtask mechanisms.
This rule provides an important guarantee: a .then(), .catch(), or .finally() callback never interrupts the JavaScript currently on the call stack.
A Promise does not make work asynchronous
Wrapping CPU-heavy work in a Promise does not move it to another thread:
const result = new Promise((resolve) => {
const value = performExpensiveCalculation();
resolve(value);
});The calculation still runs synchronously inside the executor and can block the main thread. Promises coordinate eventual outcomes; they do not provide parallel computation. CPU-intensive browser work generally needs a Web Worker, while Node.js may use worker threads or separate processes.
How Promise chaining actually works
Calling .then() does two important things:
- It registers handlers for the current Promise's outcome.
- It immediately returns a new Promise.
const first = Promise.resolve(2);
const second = first.then((value) => value * 3);
const third = second.then((value) => value + 1);
third.then(console.log); // 7The value returned from a handler determines what happens to the new Promise:
| Handler outcome | New Promise outcome |
|---|---|
| Returns a normal value | Fulfills with that value |
| Returns another Promise/thenable | Adopts its eventual outcome |
| Throws an error | Rejects with that error |
| Returns nothing | Fulfills with undefined |
That last row causes a common bug:
getUser()
.then((user) => {
getOrders(user.id); // Missing return
})
.then((orders) => {
console.log(orders); // undefined
});The first handler returns nothing, so the next Promise fulfills with undefined. The order request still starts, but the chain no longer waits for it and cannot receive its rejection.
Return the dependent Promise:
getUser()
.then((user) => getOrders(user.id))
.then((orders) => showOrders(orders));Now the second handler waits for getOrders() because its Promise becomes part of the chain.
Errors travel through Promise chains
A thrown error in a Promise handler becomes a rejection of the Promise returned by that handler:
loadProfile()
.then((profile) => {
if (!profile.email) {
throw new Error("Profile has no email address");
}
return profile.email;
})
.then(sendWelcomeEmail)
.catch((error) => {
console.error("Welcome flow failed:", error);
});When the first handler throws, JavaScript skips the following fulfillment handler and searches down the chain for a rejection handler. This resembles synchronous exception propagation, but it travels through Promise links rather than through one continuous call stack.
catch(onRejected) is effectively a clearer form of .then(undefined, onRejected). It returns a new Promise too. If the catch handler returns normally, the chain recovers and becomes fulfilled:
loadPreferences()
.catch(() => ({ theme: "system" }))
.then(applyPreferences);That recovery may be intentional. It can also hide a real failure if a catch handler only logs the error and then allows the program to continue with undefined.
What finally() does
Use .finally() for cleanup that should happen after either fulfillment or rejection:
showSpinner();
loadDashboard().then(renderDashboard).catch(showError).finally(hideSpinner);finally() does not normally replace the value or error passing through the chain. If its callback throws or returns a rejected Promise, however, that new failure becomes the chain's outcome.
What an async function actually returns
Every call to an async function returns a new Promise.
async function getAnswer() {
return 42;
}
const result = getAnswer();
console.log(result instanceof Promise); // true
result.then(console.log); // 42The return 42 does not make getAnswer() return the number directly. It fulfills the function's Promise with 42.
An uncaught throw rejects that Promise:
async function getAnswer() {
throw new Error("No answer available");
}
getAnswer().catch(console.error);If an async function returns another Promise, its own Promise adopts the returned Promise's outcome.
These rules explain why async functions compose naturally with existing Promise APIs. A caller can use .then(), return the result, or await it; the called function does not need to know which style the caller chooses.
What JavaScript does at await
Consider this function:
async function loadUser() {
console.log("before await");
const user = await fetchUser();
console.log("after await");
return user;
}When loadUser() is called, JavaScript runs its body synchronously until it reaches await.
At a useful conceptual level, JavaScript then does the following:
- Converts the awaited expression into a Promise-like outcome.
- Suspends the current async function, preserving the point where it should resume.
- Returns control to the caller along with the async function's pending Promise.
- When the awaited Promise settles, schedules a reaction to continue the function.
- Resumes the function in a later microtask with either a value or a thrown rejection.
The runtime does not freeze the thread. Other JavaScript can run while the function is suspended.
console.log("A");
async function demo() {
console.log("B");
await Promise.resolve();
console.log("D");
}
demo();
console.log("C");Output:
A
B
C
Ddemo() begins synchronously and prints B. Even though Promise.resolve() is already fulfilled, await yields. The continuation that prints D is queued for a later microtask, so the caller prints C first.
For a deeper view of this scheduling boundary, return to the event-loop guide's explanation of Promise microtasks.
Awaiting a rejection behaves like throwing
If the awaited Promise rejects, await throws the rejection reason at that location:
async function loadAccount() {
try {
const account = await fetchAccount();
return account;
} catch (error) {
console.error("Could not load account", error);
throw error;
}
}Ordinary try, catch, and finally syntax therefore works across awaited Promise boundaries. The outer async function still returns a Promise, and rethrowing rejects it so the caller can decide what to do next.
Async/await and .then() use the same machinery
These functions express nearly the same sequence:
function loadProfileWithThen() {
return getSession()
.then((session) => getProfile(session.userId))
.then((profile) => formatProfile(profile));
}
async function loadProfileWithAwait() {
const session = await getSession();
const profile = await getProfile(session.userId);
return formatProfile(profile);
}The second version often reads more naturally because control flow and error handling resemble synchronous code. It is not a separate asynchronous system. async and await build on Promise semantics.
Use the style that makes ownership and sequencing clearest. Promise chains can be elegant for short transformations. Async/await usually becomes easier to scan when a workflow contains conditions, loops, try...catch, or several named intermediate values.
Sequential execution versus concurrency
One of the most expensive async/await mistakes is accidentally waiting for independent work one operation at a time:
const user = await fetchUser();
const settings = await fetchSettings();
const notifications = await fetchNotifications();This is correct if each call depends on the previous result. If the three calls are independent, it creates an unnecessary waterfall. The second request does not start until the first finishes, and the third waits for the second.
Start independent operations together and then await their combined outcome:
const [user, settings, notifications] = await Promise.all([
fetchUser(),
fetchSettings(),
fetchNotifications(),
]);Promise.all() does not itself make code run in parallel. The three function calls start the operations as the array is created. Promise.all() gives you one Promise that fulfills when all inputs fulfill or rejects when the first input rejects.
Choosing a Promise combinator
| Method | Use it when | Outcome |
|---|---|---|
Promise.all() |
Every result is required | Fulfills with ordered values; rejects after the first rejection |
Promise.allSettled() |
Every outcome must be inspected | Fulfills with status objects after all inputs settle |
Promise.any() |
The first successful result is enough | Fulfills on first success; rejects if all inputs reject |
Promise.race() |
The first settled outcome should win | Adopts the first fulfillment or rejection |
Even after a combinator has settled, the underlying operations may continue. Promises do not cancel their inputs. Cancellation requires cooperation from the API performing the work.
Promises are not cancellation
A Promise exposes an eventual outcome, not a universal stop button. For web requests, use AbortController and pass its signal to fetch:
async function loadSearchResults(query, signal) {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal,
});
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}`);
}
return response.json();
}
const controller = new AbortController();
const request = loadSearchResults("promises", controller.signal);
controller.abort();
request.catch((error) => {
if (error.name !== "AbortError") {
console.error(error);
}
});Aborting works because fetch understands the signal. Passing a signal to your own API has no effect unless that API listens for it and stops its work.
Common JavaScript Promise mistakes
1. Forgetting to return or await a Promise
async function saveProfile(profile) {
writeProfile(profile); // The caller may finish before the write
}Use return writeProfile(profile) when the caller only needs the outcome, or await writeProfile(profile) when this function must perform more work afterward or handle the error locally.
2. Using an async callback with forEach
forEach does not wait for Promises returned by its callback:
users.forEach(async (user) => {
await sendEmail(user);
});
console.log("Finished"); // May run before any email finishesFor concurrent work, map values to Promises and combine them:
await Promise.all(users.map((user) => sendEmail(user)));For deliberate sequential work, use a loop that supports await:
for (const user of users) {
await sendEmail(user);
}3. Using an async Promise executor
Avoid this pattern:
new Promise(async (resolve, reject) => {
const value = await loadValue();
resolve(value);
});The Promise constructor expects a synchronous executor. An exception after the executor's first await belongs to the executor's implicit Promise, not automatically to the outer Promise. Usually you should return loadValue() directly or write an async function.
Use new Promise() mainly when adapting a genuinely callback-based API:
function delay(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}4. Catching an error too early
async function loadUser() {
try {
return await requestUser();
} catch (error) {
console.error(error);
}
}This function fulfills with undefined after failure. If that is not a real fallback, rethrow the error or let it propagate to a layer that can recover, retry, or show the correct user message.
5. Assuming fetch rejects for every HTTP error
fetch rejects for failures such as an aborted request or a network error. An HTTP 404 or 500 still produces a Response. Check response.ok or response.status and throw an application-level error when appropriate.
6. Creating an unhandled rejection
Starting a Promise without returning, awaiting, or attaching a rejection handler can leave failures unobserved:
async function onSubmit() {
saveForm(); // Floating Promise
}Make the ownership explicit:
async function onSubmit() {
await saveForm();
}If work is intentionally detached, attach error handling where it is launched and document why the caller should not wait.
A production-friendly async function
A reliable async function makes success, HTTP failure, parse failure, cancellation, and cleanup visible:
async function getArticle(slug, signal) {
const response = await fetch(`/api/articles/${encodeURIComponent(slug)}`, {
signal,
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Article request failed: ${response.status}`);
}
return response.json();
}
async function renderArticle(slug, signal) {
showLoadingState();
try {
const article = await getArticle(slug, signal);
showArticle(article);
} catch (error) {
if (error.name === "AbortError") {
return;
}
showArticleError(error);
throw error;
} finally {
hideLoadingState();
}
}Notice the division of responsibility. getArticle translates an unsuccessful HTTP response into a rejected Promise. renderArticle owns user-interface state and treats intentional cancellation differently from a real failure. The error is rethrown so monitoring or a higher boundary can still observe it.
A practical decision guide
When working with asynchronous JavaScript, ask these questions:
- Who owns this Promise? Return it, await it, or deliberately handle its failure at launch.
- Does this operation depend on the previous result? If not, start independent work before awaiting it.
- What does failure mean here? Recover only where a meaningful fallback exists.
- Does the operation need cancellation? Pass a cancellation signal through every cooperating layer.
- Must every result succeed? Choose the Promise combinator that matches the actual requirement.
- Am I doing CPU work? A Promise will not move it off the current thread.
These questions prevent more bugs than memorizing a preferred syntax.
Frequently asked questions
What is the difference between a Promise and async/await?
A Promise is an object representing an eventual outcome. Async/await is syntax for working with Promise-based operations. An async function returns a Promise, and await consumes a value by suspending that async function until the corresponding Promise settles.
Does await block JavaScript?
No. await suspends the surrounding async function, not the JavaScript thread. Code that does not depend on the awaited result can continue. The function resumes through a later Promise reaction.
Are Promises synchronous or asynchronous?
The executor passed to new Promise() runs synchronously. Promise reaction handlers registered with .then(), .catch(), and .finally() run asynchronously as Jobs processed through the runtime's microtask mechanism.
Should I use .then() or async/await?
Both are valid because they use the same Promise model. Async/await is often clearer for multi-step imperative control flow. Promise chains can be concise for short transformations or direct composition. Consistent ownership and error handling matter more than the surface syntax.
When should I use Promise.all()?
Use Promise.all() when independent operations can start together and every result is required. It preserves the input order of fulfilled values, even if operations finish in a different order, and rejects when an input rejects.
Can I cancel a Promise?
Not directly. The operation producing the Promise must support cancellation. Browser APIs commonly use AbortSignal; custom APIs need their own cooperative cancellation design.
The mental model to keep
JavaScript Promises separate starting an operation from observing its eventual outcome. A Promise moves from pending to a settled state and queues reactions rather than calling them inline. Every .then() creates another Promise, which is why returned values, returned Promises, and thrown errors can flow through a chain.
An async function is still a Promise-producing function. It runs synchronously until an await, suspends only itself, and resumes later through Promise scheduling. Async/await makes the control flow easier to read; it does not change the underlying concurrency model.
Once those rules are clear, asynchronous JavaScript stops looking like a collection of special cases. It becomes a predictable system of outcomes, dependencies, reactions, and ownership.
Continue through the Full-Stack Foundations and Internals articles, or read this guide in Bangla.
Standards and source notes
The state terminology, Promise adoption rules, reaction Jobs, and async-function behavior in this article follow the official ECMAScript specification for Promise and AsyncFunction objects. The explanations of Promise composition and practical usage were cross-checked against MDN's Using promises guide.

Discussion
Sign in to join the discussion.