Skip to main content

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:

  1. 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).

  2. You can go about your day. You do not stand at the door waiting.

  3. When the package arrives, your "delivery handler" runs (the .then callback). If something went wrong, your "problem handler" runs (the .catch callback).

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 Reject with Error Objects

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);
});
Always Handle Rejections

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

FeatureCallbacksPromises
Basic syntaxfn(args, callback)fn(args).then(handler)
Error handlingManual check in every callback (if (err))Single .catch() at the end of the chain
Sequential opsNested (pyramid of doom)Flat chaining (.then().then())
Multiple resultsMultiple callbacks or array argumentsPromise.all(), Promise.race(), etc.
Guaranteed asyncNot guaranteed (caller might call synchronously)Always async (microtask queue)
ComposabilityDifficultNatural (Promises are values)
Inversion of controlYou give your callback to someone elseYou receive a Promise you control
State inspectionNo standard wayPending/fulfilled/rejected
Once-only guaranteeNot 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. Call resolve(value) for success or reject(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/reject are 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 .then handlers, which the two-argument form of .then does 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 Error objects (not strings) for stack traces. Always add .catch() to every Promise chain to avoid unhandled rejections.
  • Use Promise.resolve(value) and Promise.reject(error) to create pre-settled Promises, useful for returning cached values or early validation failures while maintaining a consistent Promise-based API.