How to Use Promises in JavaScript
Promises are the foundation of modern asynchronous JavaScript. They replaced the callback pattern as the standard way to handle operations that complete in the future, solving the readability and error-handling problems that made callbacks difficult to work with at scale.
A Promise is an object that represents the eventual result of an asynchronous operation. Instead of passing a callback into a function and hoping it gets called correctly, the function returns a Promise object that you can attach handlers to. This inversion of control is the key insight: the Promise gives you a standardized, predictable way to work with future values.
Before you can understand Promise chaining, Promise.all, or async/await (covered in following articles), you need a solid grasp of how a single Promise works: how to create one, what states it transitions through, and how to consume its result or handle its failure. This guide covers exactly that.
What Is a Promise? (Producer and Consumer)
A Promise involves two parties: a producer and a consumer.
The producer creates the Promise and starts the asynchronous work. It decides whether the operation succeeds or fails.
The consumer attaches handlers to the Promise to receive the result when it becomes available.
The Analogy
Think of a Promise like ordering a book online:
-
You place the order. The store gives you a tracking number (the Promise object). The book is not in your hands yet, but you have a guarantee that either you will receive the book (fulfilled) or you will get a notification that it is out of stock (rejected).
-
You can go about your day. You do not stand at the door waiting.
-
When the package arrives, your "delivery handler" runs (the
.thencallback). If something went wrong, your "problem handler" runs (the.catchcallback).
The Promise is the tracking number. It does not contain the result yet, but it promises that a result will come.
A Simple Example
// PRODUCER: Creates the Promise and does the async work
let bookOrder = new Promise((resolve, reject) => {
// Simulate shipping delay
setTimeout(() => {
let inStock = true;
if (inStock) {
resolve("Your book has arrived!"); // Success
} else {
reject("Sorry, the book is out of stock."); // Failure
}
}, 2000);
});
// CONSUMER: Attaches handlers to receive the result
bookOrder
.then(message => {
console.log(message); // "Your book has arrived!"
})
.catch(error => {
console.log(error); // Would run if rejected
});
console.log("Order placed, continuing shopping...");
Output:
Order placed, continuing shopping...
Your book has arrived! (after ~2 seconds)
The Promise Constructor: new Promise(resolve, reject)
The Promise constructor takes a single argument: a function called the executor. The executor runs immediately when the Promise is created and receives two callback functions as arguments: resolve and reject.
Syntax
let promise = new Promise(function(resolve, reject) {
// Asynchronous operation goes here
// If successful:
resolve(value); // Marks the promise as fulfilled with this value
// If failed:
reject(error); // Marks the promise as rejected with this reason
});
The Executor Runs Immediately
This is a critical point. The executor function runs synchronously as soon as new Promise() is called:
console.log("Before Promise");
let promise = new Promise((resolve, reject) => {
console.log("Inside executor (runs immediately!)");
resolve("done");
});
console.log("After Promise");
Output:
Before Promise
Inside executor (runs immediately!)
After Promise
The executor's code runs inline, not asynchronously. It is only the result handling (.then, .catch) that happens asynchronously.
resolve and reject Are Functions
resolve and reject are functions provided by the JavaScript engine. You do not define them. You call them when you know the outcome of your operation.
// Simulating an API request
function fetchUser(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId > 0) {
resolve({ id: userId, name: "Alice", email: "alice@example.com" });
} else {
reject(new Error("Invalid user ID"));
}
}, 1000);
});
}
// Usage
fetchUser(1)
.then(user => console.log(`Got user: ${user.name}`))
.catch(err => console.error(`Error: ${err.message}`));
Only the First Call Matters
Once a Promise is resolved or rejected, it is settled. Any subsequent calls to resolve or reject are silently ignored:
let promise = new Promise((resolve, reject) => {
resolve("first"); // This one wins
resolve("second"); // Ignored
reject("error"); // Ignored
resolve("third"); // Ignored
});
promise.then(value => console.log(value)); // "first"
This is an important safety feature. A Promise can only settle once. It cannot flip between fulfilled and rejected or be resolved multiple times.
Passing Values Through resolve and reject
resolve accepts a single value (which can be any type: a string, number, object, array, or even another Promise):
// Resolving with different types
new Promise(resolve => resolve(42))
.then(val => console.log(val)); // 42
new Promise(resolve => resolve({ name: "Alice" }))
.then(val => console.log(val.name)); // "Alice"
new Promise(resolve => resolve([1, 2, 3]))
.then(val => console.log(val.length)); // 3
reject should receive an Error object (or at least an error message). While any value works technically, using Error objects preserves stack traces and follows best practices:
// ✅ Good: reject with an Error object
new Promise((resolve, reject) => {
reject(new Error("Something went wrong"));
})
.catch(err => {
console.log(err.message); // "Something went wrong"
console.log(err.stack); // Full stack trace (very useful for debugging)
});
// ❌ Avoid: reject with a plain string (no stack trace)
new Promise((resolve, reject) => {
reject("Something went wrong");
})
.catch(err => {
console.log(err); // "Something went wrong" (no stack trace)
});
Always pass new Error("message") to reject(), not plain strings or numbers. Error objects include stack traces, making debugging significantly easier. Plain strings lose this valuable diagnostic information.
Promise States: Pending, Fulfilled, Rejected (Settled)
A Promise is always in one of three states:
The Three States
1. Pending The initial state. The asynchronous operation has not completed yet. The Promise has neither a result nor an error.
2. Fulfilled
The operation completed successfully. The Promise has a result value (the argument passed to resolve()).
3. Rejected
The operation failed. The Promise has a reason for failure (the argument passed to reject()).
State Transitions
A Promise starts as pending and transitions to either fulfilled or rejected. Once settled, it never changes state again.
┌─────────┐
│ Pending │ ─── resolve(value) ──→ ┌───────────┐
│ │ │ Fulfilled │ (has result)
│ │ └───────────┘
│ │
│ │ ─── reject(error) ──→ ┌──────────┐
│ │ │ Rejected │ (has reason)
└─────────┘ └──────────┘
A settled Promise (fulfilled or rejected) NEVER changes state.
Observing States
You cannot directly read a Promise's state through a property, but you can observe it through behavior:
let pending = new Promise(() => {}); // Never resolves or rejects
let fulfilled = Promise.resolve("done");
let rejected = Promise.reject(new Error("fail"));
// The state determines which handler runs:
fulfilled.then(val => console.log("Fulfilled:", val));
// "Fulfilled: done"
rejected.catch(err => console.log("Rejected:", err.message));
// "Rejected: fail"
// pending never triggers either handler (because it never settles)
pending.then(
val => console.log("This never runs"),
err => console.log("This never runs either")
);
The Settled Concept
A Promise is settled when it is either fulfilled or rejected. Settled means the Promise has reached its final state. This terminology is important for Promise.allSettled() (covered later) and for understanding that handlers attached to already-settled Promises still run:
let promise = Promise.resolve("already done");
// Attaching a handler to an already-fulfilled Promise
// The handler still runs (asynchronously, in the microtask queue)
promise.then(val => console.log(val));
console.log("After attaching handler");
Output:
After attaching handler
already done
Even though the Promise is already fulfilled when .then is called, the handler still runs asynchronously (in the next microtask), not synchronously. This guarantees consistent behavior regardless of whether the Promise is already settled or still pending.
.then(onFulfilled, onRejected): Consuming Results
The .then() method is the primary way to consume a Promise's result. It accepts up to two arguments: a callback for the fulfilled case and a callback for the rejected case.
Basic Usage
let promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("Success!"), 1000);
});
// Attach a handler for the fulfilled case
promise.then(
result => console.log("Result:", result), // onFulfilled
error => console.log("Error:", error) // onRejected
);
// After ~1 second: "Result: Success!"
Only onFulfilled
Most commonly, you pass only the first argument and handle errors separately with .catch():
let promise = fetchUser(1);
promise.then(user => {
console.log(`Welcome, ${user.name}!`);
});
Both Handlers
let promise = new Promise((resolve, reject) => {
let success = Math.random() > 0.5;
setTimeout(() => {
if (success) {
resolve({ data: "Important information" });
} else {
reject(new Error("Request failed"));
}
}, 1000);
});
promise.then(
result => console.log("Got data:", result.data),
error => console.log("Error:", error.message)
);
.then() Returns a New Promise
This is crucial for understanding Promise chaining (covered in the next article). Every call to .then() returns a new Promise, allowing you to chain operations:
let promise = Promise.resolve(1);
let promise2 = promise.then(value => {
console.log(value); // 1
return value + 1; // This return value becomes the next Promise's result
});
promise2.then(value => {
console.log(value); // 2
});
The value returned from a .then handler becomes the fulfillment value of the new Promise that .then returns. This is the foundation of chaining.
Handlers Run Asynchronously
Even if a Promise is already settled, .then handlers are always executed asynchronously (placed in the microtask queue):
let promise = Promise.resolve("instant");
promise.then(val => console.log("Handler:", val));
console.log("Synchronous code");
Output:
Synchronous code
Handler: instant
The handler runs after the current synchronous code completes, even though the Promise was already resolved. This guarantees predictable ordering.
Multiple .then() on the Same Promise
You can attach multiple handlers to the same Promise. Each runs independently:
let promise = new Promise(resolve => {
setTimeout(() => resolve("data"), 1000);
});
// These are NOT chained: they're independent handlers on the SAME promise
promise.then(val => console.log("Handler 1:", val));
promise.then(val => console.log("Handler 2:", val));
promise.then(val => console.log("Handler 3:", val));
// After ~1 second, all three run:
// Handler 1: data
// Handler 2: data
// Handler 3: data
This is different from chaining (where each .then is called on the Promise returned by the previous .then). Here, all three handlers are attached to the same original Promise.
.catch(onRejected): Handling Errors
.catch() is specifically for handling rejections. It is equivalent to .then(null, onRejected) but much more readable.
Basic Usage
let promise = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("Network timeout"));
}, 1000);
});
promise.catch(error => {
console.log("Caught error:", error.message);
});
// After ~1 second: "Caught error: Network timeout"
.catch() Is Syntactic Sugar
These two are functionally identical:
// Using .catch()
promise.catch(error => {
console.log(error.message);
});
// Using .then() with null first argument
promise.then(null, error => {
console.log(error.message);
});
.catch() is preferred because it is more readable and clearly communicates "this handles errors."
Catching Errors Thrown Inside Handlers
.catch() also catches errors thrown inside .then() handlers, which .then(onFulfilled, onRejected) on the same call does not:
// ❌ .then with both handlers: onRejected does NOT catch errors in onFulfilled
Promise.resolve("data")
.then(
result => {
throw new Error("Error in handler!"); // This error is NOT caught below
},
error => {
// This handler catches errors from the Promise, NOT from the handler above
console.log("This won't run for handler errors");
}
);
// Uncaught error! No handler caught the throw
// ✅ .catch() catches errors from the Promise AND from previous handlers
Promise.resolve("data")
.then(result => {
throw new Error("Error in handler!");
})
.catch(error => {
console.log("Caught:", error.message); // "Caught: Error in handler!"
});
This is why the .then().catch() pattern is strongly preferred over .then(onFulfilled, onRejected).
Practical Error Handling
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId <= 0) {
reject(new Error("Invalid user ID"));
return;
}
if (userId > 1000) {
reject(new Error("User not found"));
return;
}
resolve({ id: userId, name: "Alice", email: "alice@example.com" });
}, 500);
});
}
// Consumer with proper error handling
fetchUserData(42)
.then(user => {
console.log(`Welcome, ${user.name}!`);
})
.catch(error => {
console.error(`Failed to load user: ${error.message}`);
});
// "Welcome, Alice!"
fetchUserData(-1)
.then(user => {
console.log(`Welcome, ${user.name}!`);
})
.catch(error => {
console.error(`Failed to load user: ${error.message}`);
});
// "Failed to load user: Invalid user ID"
Unhandled Rejections
If a Promise is rejected and there is no .catch() handler, the error becomes an unhandled rejection, which generates a warning in the console and can crash Node.js processes:
// ❌ No .catch() (unhandled rejection!)
let promise = new Promise((resolve, reject) => {
reject(new Error("Nobody handles me!"));
});
// UnhandledPromiseRejectionWarning: Error: Nobody handles me!
// ✅ Always add .catch()
promise.catch(err => {
console.error("Handled:", err.message);
});
Every Promise chain should end with a .catch(). Unhandled rejections can crash your Node.js process (in newer versions, they do by default) and produce confusing warnings in browsers. Make it a habit to always add error handling.
Global Unhandled Rejection Handler
As a safety net, you can listen for unhandled rejections globally:
// Browser
window.addEventListener("unhandledrejection", event => {
console.error("Unhandled rejection:", event.reason);
event.preventDefault(); // Prevent default console error
});
// Node.js
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection:", reason);
});
This is a safety net, not a replacement for proper error handling. Always attach .catch() to your Promise chains.
.finally(): Cleanup Logic
.finally() runs a callback when the Promise is settled, regardless of whether it was fulfilled or rejected. It is used for cleanup operations that should happen in both cases.
Basic Usage
let isLoading = true;
fetchUserData(42)
.then(user => {
console.log(`Got user: ${user.name}`);
})
.catch(error => {
console.error(`Error: ${error.message}`);
})
.finally(() => {
isLoading = false;
console.log("Loading complete (success or failure)");
});
Key Characteristics
1. The callback receives no arguments:
Promise.resolve("data")
.finally((value) => {
console.log(value); // undefined (finally gets no arguments!)
});
Promise.reject(new Error("fail"))
.finally((error) => {
console.log(error); // undefined (finally gets no arguments!)
});
.finally() is not meant to process the result or the error. It is for cleanup that does not depend on the outcome.
2. It passes through the result or error:
.finally() does not change the Promise's value. It transparently passes through whatever the previous Promise settled with:
Promise.resolve("hello")
.finally(() => {
console.log("Cleanup");
// Return value from finally is ignored
return "this is ignored";
})
.then(value => {
console.log(value); // "hello" (the original value passes through)
});
Promise.reject(new Error("fail"))
.finally(() => {
console.log("Cleanup");
})
.catch(error => {
console.log(error.message); // "fail" (the original error passes through)
});
3. If finally throws, it overrides the result with an error:
Promise.resolve("hello")
.finally(() => {
throw new Error("Cleanup failed!");
})
.then(value => {
console.log("This won't run");
})
.catch(error => {
console.log(error.message); // "Cleanup failed!"
});
Practical Use Cases
// Loading indicator
function loadData() {
showLoadingSpinner();
return fetchData()
.then(data => {
displayData(data);
return data;
})
.catch(error => {
displayError(error.message);
})
.finally(() => {
hideLoadingSpinner(); // Always hide, success or failure
});
}
// Database connection cleanup
function queryDatabase(sql) {
let connection;
return openConnection()
.then(conn => {
connection = conn;
return conn.query(sql);
})
.then(results => {
return results;
})
.catch(error => {
console.error("Query failed:", error);
throw error; // Re-throw so the caller knows it failed
})
.finally(() => {
if (connection) {
connection.close(); // Always close the connection
console.log("Connection closed");
}
});
}
// Timer cleanup
function fetchWithTimeout(url, ms) {
let timeoutId;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error("Timeout")), ms);
fetch(url)
.then(resolve)
.catch(reject);
})
.finally(() => {
clearTimeout(timeoutId); // Always clear the timer
});
}
The Order: .then, .catch, .finally
The typical pattern:
doAsyncOperation()
.then(result => {
// Handle success
})
.catch(error => {
// Handle failure
})
.finally(() => {
// Cleanup (runs after either .then or .catch)
});
Promise vs. Callback Comparison
Let's compare the same operations using callbacks and Promises to see the practical improvements.
Simple Async Operation
// CALLBACK VERSION
function getUserCallback(id, callback) {
setTimeout(() => {
if (id <= 0) {
callback(new Error("Invalid ID"), null);
return;
}
callback(null, { id, name: "Alice" });
}, 500);
}
getUserCallback(1, (err, user) => {
if (err) {
console.error(err.message);
return;
}
console.log(user.name);
});
// PROMISE VERSION
function getUserPromise(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error("Invalid ID"));
return;
}
resolve({ id, name: "Alice" });
}, 500);
});
}
getUserPromise(1)
.then(user => console.log(user.name))
.catch(err => console.error(err.message));
Multiple Sequential Operations
// CALLBACK VERSION: callback hell
getUser(1, (err, user) => {
if (err) { handleError(err); return; }
getOrders(user.id, (err, orders) => {
if (err) { handleError(err); return; }
getOrderDetails(orders[0].id, (err, details) => {
if (err) { handleError(err); return; }
console.log("Order details:", details);
});
});
});
// PROMISE VERSION: flat chain
getUser(1)
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => console.log("Order details:", details))
.catch(err => handleError(err)); // One catch handles ALL errors
Comparison Table
| Feature | Callbacks | Promises |
|---|---|---|
| Basic syntax | fn(args, callback) | fn(args).then(handler) |
| Error handling | Manual check in every callback (if (err)) | Single .catch() at the end of the chain |
| Sequential ops | Nested (pyramid of doom) | Flat chaining (.then().then()) |
| Multiple results | Multiple callbacks or array arguments | Promise.all(), Promise.race(), etc. |
| Guaranteed async | Not guaranteed (caller might call synchronously) | Always async (microtask queue) |
| Composability | Difficult | Natural (Promises are values) |
| Inversion of control | You give your callback to someone else | You receive a Promise you control |
| State inspection | No standard way | Pending/fulfilled/rejected |
| Once-only guarantee | Not guaranteed (callback might be called twice) | Guaranteed (resolve/reject only once) |
Inversion of Control
One of the deepest advantages of Promises is solving the inversion of control problem.
With callbacks, you hand your function to external code and trust it to:
- Call it at the right time
- Call it only once
- Pass the correct arguments
- Not swallow errors
With Promises, the external code returns a Promise to you, and you decide how to handle it:
// CALLBACK: You trust the library to call your callback correctly
thirdPartyLibrary.doWork(myData, (err, result) => {
// Will this be called? Once? Twice? With the right args?
// You have no control.
});
// PROMISE: The library gives you a Promise, you're in control
let promise = thirdPartyLibrary.doWork(myData);
// YOU decide when and how to handle the result
promise
.then(result => {
// You know this runs at most once
// You know it runs asynchronously
// You know the result is the fulfilled value
})
.catch(error => {
// You know this handles any error in the chain
});
Creating Pre-Settled Promises
JavaScript provides shorthand for creating Promises that are already settled:
Promise.resolve(value)
Creates a Promise that is immediately fulfilled with the given value:
let promise = Promise.resolve(42);
promise.then(val => console.log(val)); // 42
// Useful for starting a chain or wrapping synchronous values
function getConfig(key) {
if (cache.has(key)) {
return Promise.resolve(cache.get(key)); // Return cached value as a Promise
}
return fetchConfig(key); // Returns a Promise from async fetch
}
// Both paths return a Promise, so the caller's code is the same:
getConfig("theme")
.then(value => console.log("Config:", value));
Promise.reject(reason)
Creates a Promise that is immediately rejected:
let promise = Promise.reject(new Error("Something failed"));
promise.catch(err => console.log(err.message)); // "Something failed"
// Useful for early validation
function processAge(age) {
if (typeof age !== "number" || age < 0) {
return Promise.reject(new Error("Invalid age"));
}
return fetchAgeRelatedData(age);
}
Wrapping Synchronous Values
Promises can wrap synchronous operations to provide a consistent async interface:
function getUser(id) {
// Cached users returned immediately (but still as a Promise)
if (userCache[id]) {
return Promise.resolve(userCache[id]);
}
// Non-cached users fetched asynchronously
return fetch(`/api/users/${id}`)
.then(res => res.json())
.then(user => {
userCache[id] = user;
return user;
});
}
// The caller doesn't need to know whether the result was cached or fetched
getUser(1).then(user => console.log(user.name));
Practical Examples
Wrapping setTimeout in a Promise
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
delay(2000).then(() => console.log("2 seconds passed"));
// Chain with other operations
console.log("Starting...");
delay(1000)
.then(() => {
console.log("1 second passed");
return delay(1000);
})
.then(() => {
console.log("2 seconds total");
});
Wrapping an Image Load
function loadImage(src) {
return new Promise((resolve, reject) => {
let img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
loadImage("photo.jpg")
.then(img => {
document.body.appendChild(img);
console.log(`Image loaded: ${img.width}x${img.height}`);
})
.catch(err => {
console.error(err.message);
});
Wrapping a Geolocation Request
function getCurrentPosition() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error("Geolocation not supported"));
return;
}
navigator.geolocation.getCurrentPosition(
position => resolve({
lat: position.coords.latitude,
lng: position.coords.longitude
}),
error => reject(new Error(`Geolocation failed: ${error.message}`))
);
});
}
getCurrentPosition()
.then(coords => console.log(`You are at ${coords.lat}, ${coords.lng}`))
.catch(err => console.error(err.message));
Converting a Callback-Based Function
// Original callback-based function
function readFileCallback(path, callback) {
setTimeout(() => {
if (!path) {
callback(new Error("Path required"), null);
return;
}
callback(null, `Contents of ${path}`);
}, 500);
}
// Promise wrapper
function readFilePromise(path) {
return new Promise((resolve, reject) => {
readFileCallback(path, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
// Now you can use it with .then/.catch
readFilePromise("data.txt")
.then(data => console.log(data))
.catch(err => console.error(err.message));
Summary
- A Promise is an object representing the eventual result of an asynchronous operation. It separates the producer (who creates and resolves/rejects the Promise) from the consumer (who attaches handlers with
.then/.catch). - The Promise constructor takes an executor function:
new Promise((resolve, reject) => { ... }). The executor runs immediately. Callresolve(value)for success orreject(error)for failure. - A Promise has three states: pending (initial), fulfilled (resolved with a value), or rejected (rejected with a reason). Once settled, a Promise never changes state again. Additional calls to
resolve/rejectare silently ignored. .then(onFulfilled, onRejected)attaches handlers for the result. It returns a new Promise, enabling chaining. Handlers always run asynchronously (in the microtask queue), even for already-settled Promises..catch(onRejected)handles rejections. It is equivalent to.then(null, onRejected)but is more readable. It also catches errors thrown inside previous.thenhandlers, which the two-argument form of.thendoes not..finally(callback)runs cleanup code regardless of the outcome. It receives no arguments and passes the result/error through transparently. Use it for hiding spinners, closing connections, or clearing timers.- Promises solve the key problems with callbacks: flat chaining instead of nested pyramids, single error handler for an entire chain, guaranteed async execution, once-only settlement, and inversion of control (you receive a Promise instead of handing over a callback).
- Always reject with
Errorobjects (not strings) for stack traces. Always add.catch()to every Promise chain to avoid unhandled rejections. - Use
Promise.resolve(value)andPromise.reject(error)to create pre-settled Promises, useful for returning cached values or early validation failures while maintaining a consistent Promise-based API.