JavaScript Promises: A Complete Guide to Async Workflows
Promises are JavaScript’s built-in way to represent work that finishes later, such as fetching data, waiting for a timer, or reading a file in an environment that supports it. They help you write asynchronous code that is easier to read, chain, and debug than deeply nested callbacks.
Quick answer: A promise is an object that stands for a future result. It can be pending, fulfilled, or rejected, and you handle its outcome with then(), catch(), and finally().
Difficulty: Beginner
You'll understand this better if you know: basic JavaScript variables, functions, and how callbacks work.
1. What Is Promises?
A promise is a JavaScript object that represents the eventual result of an asynchronous operation. Instead of giving you the result immediately, it gives you a handle you can use later when the work finishes.
- A promise represents a value that may not be available yet.
- It can succeed with a value or fail with a reason.
- It helps separate the act of starting async work from the act of handling the result.
- It is the foundation for modern JavaScript async patterns, including async/await.
Promises are especially useful when an operation takes time or depends on outside systems, such as network requests or timers.
2. Why Promises Matter
Before promises, asynchronous JavaScript often relied on callbacks. Callbacks work, but complex flows can become hard to read and easy to break.
Promises matter because they make async code easier to compose. You can chain steps, centralize error handling, and combine multiple async operations in a predictable way.
They also give you a standard interface. Many Web APIs and libraries return promises, so learning them helps you use the wider JavaScript ecosystem effectively.
3. Basic Syntax or Core Idea
A promise starts in the pending state. It becomes fulfilled when the operation succeeds or rejected when it fails.
Creating a promise
The Promise constructor receives an executor function with two parameters: resolve and reject. Call resolve to complete successfully, or reject to fail.
const promise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("Done!");
} else {
reject(new Error("Something went wrong"));
}
});This example creates a promise that either resolves with a success message or rejects with an error object.
Reading a promise result
Use then() for success, catch() for failure, and finally() for cleanup that should run either way.
promise
.then((value) => {
console.log(value);
})
.catch((error) => {
console.error(error);
})
.finally(() => {
console.log("Finished");
});The chain shows the standard promise workflow: handle success, handle failure, and optionally clean up afterward.
4. Step-by-Step Examples
Example 1: A simple resolved promise
This example shows the smallest useful promise chain. It resolves immediately and passes a value into then().
const greeting = Promise.resolve("Hello from a promise");
greeting.then((message) => {
console.log(message);
});This is a convenient way to wrap a known value in a promise-shaped result.
Example 2: Delayed work with setTimeout
Promises are often used to turn timer-based code into a cleaner flow. Here, the promise resolves after a short delay.
function wait(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
wait(1000).then(() => {
console.log("One second later");
});This pattern is common when you want to pause between steps without nesting callbacks.
Example 3: Chaining multiple asynchronous steps
One of the biggest benefits of promises is chaining. Each then() can return a new value or another promise.
function getUserId() {
return Promise.resolve(42);
}
function getUserName(id) {
return Promise.resolve(`User #${id}`);
}
getUserId()
.then((id) => getUserName(id))
.then((name) => {
console.log(name);
});This example shows how each step can feed the next without deeply nested code.
Example 4: Handling failure with catch
When a promise rejects, control moves to the nearest catch() handler in the chain.
const loadData = new Promise((resolve, reject) => {
reject(new Error("Network request failed"));
});
loadData
.then(() => {
console.log("This will not run");
})
.catch((error) => {
console.error(error.message);
});This is the standard way to handle errors from promise-based work.
Example 5: Cleaning up with finally
The finally() method runs after fulfillment or rejection. Use it for cleanup that does not depend on success or failure.
const task = Promise.resolve("OK");
task
.then((result) => {
console.log(result);
})
.finally(() => {
console.log("Cleanup runs here");
});This pattern is useful for hiding loaders, stopping spinners, or releasing resources.
5. Practical Use Cases
- Fetching data from an API with fetch().
- Waiting for a delay between steps in a workflow.
- Running several independent async tasks and combining their results.
- Turning a callback-based API into a cleaner, promise-based helper.
- Handling success and failure in a single consistent flow.
Promises are a strong fit whenever you need to represent an eventual result and keep the control flow readable.
6. Common Mistakes
Mistake 1: Forgetting to return a promise from a chain step
Each step in a promise chain must return the next value or promise when you want the chain to continue with that result. If you forget to return, the next handler receives undefined.
Problem: The second handler runs before the asynchronous function finishes because the first then() does not return the promise.
getUserId()
.then((id) => {
getUserName(id);
})
.then((name) => {
console.log(name);
});Fix: Return the promise so the chain waits for it.
getUserId()
.then((id) => {
return getUserName(id);
})
.then((name) => {
console.log(name);
});The corrected version works because the chain waits for the returned promise to resolve.
Mistake 2: Throwing away errors instead of handling them
Rejected promises should be handled with catch(). If you skip error handling, failures can become unhandled promise rejections.
Problem: This code has no rejection handler, so a failed promise can surface as an unhandled rejection in the console or runtime.
const request = Promise.reject(new Error("Request failed"));
request.then((value) => {
console.log(value);
});Fix: Add catch() to handle the failure path.
const request = Promise.reject(new Error("Request failed"));
request
.then((value) => {
console.log(value);
})
.catch((error) => {
console.error(error.message);
});The corrected version prevents silent failures and gives you a predictable fallback.
Mistake 3: Trying to use a promise as if it were the final value
A promise is not the resolved value itself. If you log it too early or access properties before it resolves, you will see the promise object instead of the data you expect.
Problem: This code prints the promise object because the result is still asynchronous when console.log runs.
const userPromise = Promise.resolve({ name: "Ada" });
console.log(userPromise.name);Fix: Read the resolved value inside then().
const userPromise = Promise.resolve({ name: "Ada" });
userPromise.then((user) => {
console.log(user.name);
});The corrected version works because it uses the actual resolved object instead of the promise wrapper.
7. Best Practices
Practice 1: Use promises for one-time async results
Promises are a good fit for a single operation that eventually succeeds or fails. They are not a general replacement for events or repeated notifications.
function saveProfile(profile) {
return new Promise((resolve) => {
setTimeout(() => resolve(profile), 500);
});
}This keeps the API simple and communicates that the result arrives once.
Practice 2: Always reject with an Error object when possible
Using an Error object makes failures easier to inspect and debug because it carries a message and stack trace.
function loadSettings() {
return new Promise((resolve, reject) => {
const ok = false;
if (ok) {
resolve({ theme: "dark" });
} else {
reject(new Error("Unable to load settings"));
}
});
}This makes debugging and downstream handling much clearer than rejecting with an unclear raw value.
Practice 3: Keep promise chains readable and focused
Each then() should ideally do one job. If a chain becomes too long, move logic into named functions so the flow stays understandable.
function parseUser(response) {
return response.json();
}
function showUser(user) {
console.log(user.name);
}
fetch("/api/user")
.then(parseUser)
.then(showUser)
.catch((error) => {
console.error(error);
});Named functions make the chain easier to scan and reuse.
8. Limitations and Edge Cases
- A promise settles only once. After it is fulfilled or rejected, its state does not change again.
- then() callbacks run asynchronously after the current synchronous code finishes, even when the promise is already resolved.
- Promises do not cancel themselves. If you need cancellation, you usually need a separate API such as AbortController in browser-based workflows.
- A thrown error inside then() behaves like a rejection and moves to the nearest catch().
- Promise.all() rejects as soon as one input promise rejects, which can surprise developers expecting partial results.
A common “not working” complaint is that a value is available later than expected. That is normal: promise handlers always run after the current synchronous call stack finishes.
9. Practical Mini Project
Here is a small promise-based task runner that simulates loading a user profile, waiting, and then showing the result. It demonstrates promise creation, chaining, and error handling in one place.
function wait(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
function loadProfile() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
name: "Amina",
role: "Developer"
});
}, 600);
});
}
wait(300)
.then(() => loadProfile())
.then((profile) => {
console.log(`Name: ${profile.name}`);
console.log(`Role: ${profile.role}`);
})
.catch((error) => {
console.error("Failed to load profile:", error);
})
.finally(() => {
console.log("Task complete");
});This mini project shows how promise chains can coordinate sequential async steps without deeply nested callbacks.
10. Key Points
- Promises represent future values from asynchronous work.
- They have three states: pending, fulfilled, and rejected.
- Use then() for success, catch() for failure, and finally() for cleanup.
- Return promises from chain steps when the next step depends on them.
- Promises are the basis for many modern async JavaScript patterns.
11. Practice Exercise
Create a small promise helper that waits for a delay and then returns a message. Then chain a second step that transforms the message to uppercase and handles any error with catch().
- Write a function named delayMessage that returns a promise.
- The promise should resolve after 500 milliseconds with the string "hello".
- Chain a second then() to convert the message to uppercase.
- Add catch() to handle errors.
Expected output: HELLO
Hint: Use setTimeout() inside the promise constructor, and remember to return the promise from your helper.
function delayMessage() {
return new Promise((resolve) => {
setTimeout(() => {
resolve("hello");
}, 500);
});
}
delayMessage()
.then((message) => message.toUpperCase())
.then((message) => {
console.log(message);
})
.catch((error) => {
console.error(error);
});12. Final Summary
Promises are JavaScript’s standard way to work with values that arrive later. They give you a clear model for success, failure, and cleanup, and they help you avoid the complexity of nested callbacks.
Once you understand how to create a promise, chain then() calls, handle errors with catch(), and clean up with finally(), you have the core mental model needed for modern async JavaScript.
Next, a natural follow-up is async/await, which builds on promises and gives you an even more synchronous-looking way to write asynchronous code.