Loading content...
When you order a book online, you receive something immediately: a tracking number. This number doesn't give you the book—it gives you a promise that the book will arrive. You can check the status, wait for delivery, or plan what to do when it arrives.
The tracking number is a placeholder for a future value. It's a first-class object you can pass around, store, and compose with other operations—all before the actual value exists.
This is the insight behind Futures and Promises in computing: instead of passing callbacks to async operations, the operation returns an object representing the eventual result. This simple shift transforms async programming from callback spaghetti into composable, readable code.
By the end of this page, you will understand what futures and promises are, their historical evolution, the distinction between futures and promises, promise states and transitions, chaining and composition, error propagation, and how promises solve the core problems of callbacks.
A future (or promise—terminology varies by language) is an object that represents a value that may not yet be available but will be at some point, or might fail to become available.
The key insight:
Instead of:
asyncOperation(input, function(error, result) {
// Handle result here, inside this callback
});
We have:
let promise = asyncOperation(input);
// promise is an OBJECT representing the eventual result
// We can pass it around, store it, compose it
What can you do with a future/promise?
| Term | Languages/Frameworks | Notes |
|---|---|---|
| Promise | JavaScript, Scala, some C++ | Read-only view of eventual value |
| Future | Java (CompletableFuture), Rust, C++ | May be read-only or completable |
| Deferred | jQuery, Twisted (Python) | Often the 'producer' side of the pattern |
| Task | C# (.NET), Swift | Represents async operation with result |
| CompletionStage | Java | Interface for chainable async stages |
Historical evolution:
The concept appeared in academic literature in the 1970s (Friedman & Wise, Hibbard), but practical adoption came later:
Today, virtually every major language has some form of future/promise abstraction.
The term 'promise' comes from the E programming language (1997), which wanted to emphasize the 'contract' aspect: the object promises to eventually hold a value. The 'future' terminology emphasizes temporality—the value will exist in the future. Both describe the same abstraction.
A promise is a state machine with well-defined states and transitions. Understanding this model is essential for reasoning about async code.
The three states:
Pending: Initial state. The async operation is in progress. No value or error yet.
Fulfilled (Resolved): The operation completed successfully. The promise now has a value.
Rejected: The operation failed. The promise now has a reason (error).
Critical property: Immutability
Once a promise transitions from pending to fulfilled or rejected, it cannot change state again. This is a fundamental guarantee that simplifies reasoning:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Demonstrating promise state transitions // Creating a pending promiseconst promise = new Promise((resolve, reject) => { console.log("Promise is PENDING"); setTimeout(() => { // Transition to FULFILLED resolve("Success value"); // This promise is now permanently fulfilled // These have NO effect - state cannot change resolve("Another value"); // Ignored reject("An error"); // Ignored }, 1000);}); // Checking state (conceptually - actual state is internal)// promise is PENDING here promise.then(value => { console.log("Promise is FULFILLED with:", value); // Output: "Promise is FULFILLED with: Success value"}); // ========================================// Creating rejected promises// ======================================== const rejected = new Promise((resolve, reject) => { reject(new Error("Something failed")); // Again, subsequent calls are ignored resolve("This won't work");}); rejected.catch(error => { console.log("Promise is REJECTED with:", error.message);}); // ========================================// State transition diagram// ======================================== /* ┌─────────────────────────────┐ │ │ ┌───────┐ │ ┌──────────┐ │ │PENDING│───────┼────────►│FULFILLED │ │ └───────┘ │resolve()└──────────┘ │ │ │ │ │ │ │ (immutable) │ │ │ │ │reject() │ │ │ │ ┌──────────┐ │ └───────────┼────────►│REJECTED │ │ │ └──────────┘ │ │ │ │ │ (immutable) │ │ │ └─────────────────────────────┘*/ // ========================================// "Settled" = Fulfilled OR Rejected// ======================================== // A promise is "settled" when it's no longer pending// Settled promises have a final value/reason Promise.allSettled([ Promise.resolve(1), Promise.reject(new Error("fail")), Promise.resolve(3)]).then(results => { // All promises have settled (regardless of fulfill/reject) console.log(results); // [ // { status: "fulfilled", value: 1 }, // { status: "rejected", reason: Error }, // { status: "fulfilled", value: 3 } // ]});Why immutability matters:
Predictability: Once you observe a promise in a state, it stays that way. No race conditions on the promise itself.
Memoization: Handlers attached after settlement receive the same value immediately. The promise 'remembers' its result.
Safe sharing: Multiple consumers can use the same promise without interfering with each other.
Composition: Promise combinators (all, race, etc.) can reason about states without worrying about changes.
Contrast with callbacks:
With callbacks, you might accidentally call the callback multiple times. Promises prevent this by design—the state transition happens exactly once.
In JavaScript, promises start executing immediately when created. The async work begins even if you never attach a .then() handler. This is 'eager' evaluation. Some languages (like Rust with futures) use 'lazy' evaluation where work only starts when the future is polled.
Promises have two sides: the consumer side (using .then() and .catch()) and the producer side (creating and resolving promises). Understanding both is essential.
The Promise constructor pattern:
new Promise(function executor(resolve, reject) {
// resolve(value) - transition to fulfilled
// reject(reason) - transition to rejected
});
The executor function receives two functions: resolve to fulfill the promise and reject to reject it.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// ========================================// Pattern 1: Wrapping callback-based APIs// ======================================== // Original callback APIfunction readFileCallback(path, callback) { fs.readFile(path, 'utf8', callback);} // Promisified versionfunction readFilePromise(path) { return new Promise((resolve, reject) => { fs.readFile(path, 'utf8', (err, data) => { if (err) { reject(err); } else { resolve(data); } }); });} // UsagereadFilePromise('/path/to/file') .then(data => console.log(data)) .catch(err => console.error(err)); // ========================================// Pattern 2: Timer-based promises// ======================================== function delay(ms) { return new Promise(resolve => { setTimeout(() => resolve(), ms); });} function timeout(ms, message) { return new Promise((_, reject) => { setTimeout(() => reject(new Error(message)), ms); });} // Usagedelay(1000).then(() => console.log("1 second passed")); // ========================================// Pattern 3: Conditional resolution// ======================================== function fetchWithValidation(url) { return new Promise((resolve, reject) => { if (!url.startsWith('https://')) { reject(new Error('Only HTTPS URLs allowed')); return; } fetch(url) .then(response => { if (!response.ok) { reject(new Error(`HTTP ${response.status}`)); } else { resolve(response.json()); } }) .catch(reject); // Network errors });} // ========================================// Pattern 4: Promise-based event listener// ======================================== function waitForEvent(element, eventName) { return new Promise(resolve => { function handler(event) { element.removeEventListener(eventName, handler); resolve(event); } element.addEventListener(eventName, handler); });} // Usageconst clickEvent = await waitForEvent(button, 'click'); // ========================================// Static creation methods// ======================================== // Already fulfilledconst fulfilled = Promise.resolve(42); // Already rejected const rejected = Promise.reject(new Error("Failed")); // Resolve with another promise (adopts its state)const adopted = Promise.resolve(someOtherPromise);Resolving with a promise (promise adoption):
A crucial behavior: if you resolve() with another promise (or thenable), your promise adopts the state of that promise:
const outer = new Promise(resolve => {
const inner = fetchData(); // returns a promise
resolve(inner); // outer adopts inner's eventual state
});
// outer doesn't fulfill until inner fulfills
// outer rejects if inner rejects
This is called 'promise assimilation' or 'unwrapping' and enables transparent composition.
The reject vs throw distinction:
Inside the executor, both work similarly:
// These are equivalent:
new Promise((resolve, reject) => {
reject(new Error("failed"));
});
new Promise((resolve, reject) => {
throw new Error("failed"); // Automatically caught and rejects
});
However, throw inside async callbacks won't be caught:
new Promise((resolve, reject) => {
setTimeout(() => {
throw new Error("Not caught!"); // Uncaught exception!
// Must use: reject(new Error(...))
}, 100);
});
The executor function runs synchronously when the Promise is constructed. Only the resolve/reject notification is asynchronous. This means any sync errors in the executor are caught, but code before the promise is created runs before the promise exists.
The transformative power of promises comes from chaining. Each .then() returns a new promise, enabling elegant sequential async operations without nesting.
The chaining rule:
When you call .then(handler), the returned promise:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// ========================================// Basic chaining - compare to callback hell// ======================================== // CALLBACK VERSION (pyramid of doom)getUser(userId, (err, user) => { if (err) return handleError(err); getOrders(user.id, (err, orders) => { if (err) return handleError(err); getProducts(orders[0].productId, (err, product) => { if (err) return handleError(err); console.log(product); }); });}); // PROMISE VERSION (flat chain)getUser(userId) .then(user => getOrders(user.id)) .then(orders => getProducts(orders[0].productId)) .then(product => console.log(product)) .catch(err => handleError(err)); // ONE error handler for all! // ========================================// Understanding the chain mechanics// ======================================== Promise.resolve(1) .then(x => { console.log("Step 1:", x); // 1 return x + 1; // Return value becomes next value }) .then(x => { console.log("Step 2:", x); // 2 return x * 2; }) .then(x => { console.log("Step 3:", x); // 4 // Return a promise - chain waits for it return new Promise(resolve => { setTimeout(() => resolve(x + 10), 1000); }); }) .then(x => { console.log("Step 4:", x); // 14 (after 1 second) }); // ========================================// Each .then() creates a NEW promise// ======================================== const p1 = Promise.resolve(1);const p2 = p1.then(x => x + 1);const p3 = p2.then(x => x + 1); console.log(p1 === p2); // false - different promises!console.log(p2 === p3); // false // You can branch from any promiseconst base = fetchUser(userId); const branch1 = base.then(user => user.name);const branch2 = base.then(user => user.email);const branch3 = base.then(user => fetchOrders(user.id)); // All three branches stem from the same base promise// They execute independently // ========================================// Value transformation vs async operations// ======================================== fetchUser(userId) // Sync transformation - just return the value .then(user => user.name.toUpperCase()) // Async operation - return a promise .then(name => sendNotification(`Hello ${name}`)) // The chain handles both seamlessly .then(result => console.log("Notification sent:", result));Why chaining solves callback hell:
.then() calls.catch() at the end handles all errorsReturning values vs returning undefined:
// WRONG - forgetting to return
fetchData()
.then(data => {
processData(data); // Oops! Didn't return
})
.then(result => {
console.log(result); // undefined!
});
// CORRECT - explicit return
fetchData()
.then(data => {
return processData(data);
})
.then(result => {
console.log(result); // Actual result
});
// CLEANER - arrow function implicit return
fetchData()
.then(data => processData(data))
.then(result => console.log(result));
Think of a promise chain as a pipeline. Data flows through transformations, each .then() is a stage. Some stages are sync (pure transforms), some are async (return promises). The chain abstracts away the difference.
Promises provide elegant error handling through the rejection propagation mechanism. Errors flow down the chain until caught, similar to exceptions bubbling up the call stack.
The propagation rule:
If a promise in a chain rejects (or a handler throws), the rejection skips subsequent .then() handlers and propagates to the next .catch() or rejection handler.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
// ========================================// Error propagation through the chain// ======================================== fetchUser(userId) .then(user => { if (!user.active) { throw new Error("User is inactive"); // Rejection! } return fetchOrders(user.id); }) .then(orders => { // SKIPPED if previous threw return processOrders(orders); }) .then(result => { // SKIPPED if any earlier step failed return formatResult(result); }) .catch(error => { // Catches ANY error from the entire chain console.error("Operation failed:", error.message); }); // ========================================// .catch() is sugar for .then(null, handler)// ======================================== // These are equivalent:promise.catch(handleError);promise.then(null, handleError); // But .catch() is clearer and preferred // ========================================// Recovery in catch handlers// ======================================== fetchFromPrimary(id) .catch(error => { console.log("Primary failed, trying backup..."); return fetchFromBackup(id); // Return new promise }) .then(data => { // Gets data from whichever succeeded console.log("Got data:", data); }) .catch(error => { // Only if BOTH failed console.error("All sources failed:", error); }); // ========================================// Error typing and handling// ======================================== fetchResource(url) .catch(error => { if (error.name === 'NetworkError') { return cachedVersion(url); // Recover } if (error.status === 404) { return null; // Not found is okay, return null } throw error; // Re-throw unknown errors }) .then(resource => { if (resource === null) { console.log("Resource not found"); } else { processResource(resource); } }); // ========================================// .finally() - cleanup regardless of outcome// ======================================== let connection; connectToDatabase() .then(conn => { connection = conn; return queryDatabase(conn, sql); }) .then(results => { return processResults(results); }) .catch(error => { console.error("Database operation failed:", error); throw error; // Re-throw after logging }) .finally(() => { // Runs whether fulfilled OR rejected // Does NOT receive the value/error // Return value is ignored (doesn't affect chain) if (connection) { connection.close(); } }); // ========================================// Common mistake: catch placement// ======================================== // WRONG - catch doesn't protect later operationsfetchData() .catch(err => handleFetchError(err)) .then(data => processData(data)) // Might receive undefined! .then(result => saveResult(result)); // Error here not caught! // RIGHT - catch at the end catches everythingfetchData() .then(data => processData(data)) .then(result => saveResult(result)) .catch(err => handleAnyError(err)); // OR - multiple catch blocks for different handlingfetchData() .catch(err => { if (recoverableError(err)) { return getDefaultData(); } throw err; }) .then(data => processData(data)) .catch(err => notifyUser(err));Key error handling patterns:
| Pattern | Description | Use Case |
|---|---|---|
| End catch | Single .catch() at chain end | Simple pipelines |
| Recovery catch | .catch() that returns value | Fallbacks, defaults |
| Re-throw | throw error in catch | Log then propagate |
| Transform error | throw new BetterError(error) | Error enrichment |
| Finally cleanup | .finally() for cleanup | Resource release |
Unhandled rejections:
If a promise rejects and there's no .catch() handler, you have an unhandled rejection. Modern runtimes treat these seriously:
// Node.js behavior:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
// In Node.js 15+, this crashes the process by default
});
// Browser behavior:
window.addEventListener('unhandledrejection', event => {
console.error('Unhandled rejection:', event.reason);
});
Every promise chain should end with a .catch() handler. Unhandled rejections are silent failures that hide bugs. Linters and runtime warnings exist specifically to catch missing rejection handlers.
Real applications often need to coordinate multiple async operations. Promise combinators are methods that take multiple promises and return a new promise based on their collective behavior.
The standard combinators:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// ========================================// Promise.all - Wait for ALL to succeed// ======================================== const userPromise = fetchUser(userId);const ordersPromise = fetchOrders(userId);const analyticsPromise = fetchAnalytics(userId); // All three requests run CONCURRENTLY// Promise.all waits for ALL to complete Promise.all([userPromise, ordersPromise, analyticsPromise]) .then(([user, orders, analytics]) => { // ALL succeeded - array of results in order console.log("User:", user.name); console.log("Orders:", orders.length); console.log("Analytics:", analytics.pageViews); }) .catch(error => { // ANY failure rejects immediately (fast-fail) // Other pending promises continue but results are discarded console.error("One request failed:", error); }); // ========================================// Promise.allSettled - Wait for all to complete// ======================================== // Doesn't short-circuit on failurePromise.allSettled([ fetch('/api/primary'), fetch('/api/secondary'), fetch('/api/tertiary')]).then(results => { results.forEach((result, index) => { if (result.status === 'fulfilled') { console.log(`Request ${index} succeeded:`, result.value); } else { console.log(`Request ${index} failed:`, result.reason); } }); // Can proceed even if some failed const successfulResults = results .filter(r => r.status === 'fulfilled') .map(r => r.value);}); // ========================================// Promise.race - First to settle wins// ======================================== // Timeout patternfunction fetchWithTimeout(url, ms) { return Promise.race([ fetch(url), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms) ) ]);} fetchWithTimeout('/api/slow-endpoint', 5000) .then(response => console.log("Got response in time")) .catch(error => console.log("Timed out or failed:", error.message)); // ========================================// Promise.any - First to SUCCEED wins// ======================================== // Try multiple sources, use first successfulPromise.any([ fetchFromMirror1(file), fetchFromMirror2(file), fetchFromMirror3(file)]).then(data => { console.log("Got file from fastest mirror:", data);}).catch(error => { // AggregateError - ALL failed console.log("All mirrors failed:", error.errors);}); // ========================================// Comparison table// ======================================== /*| Combinator | Resolves when... | Rejects when... ||------------------|-------------------------|------------------------|| Promise.all | ALL fulfill | ANY rejects || Promise.allSettled| ALL settle | Never (always fulfills)|| Promise.race | FIRST settles | FIRST rejects || Promise.any | FIRST fulfills | ALL reject |*/ // ========================================// Practical pattern: Concurrent with limit// ======================================== async function mapWithConcurrency(items, fn, concurrency) { const results = []; const executing = new Set(); for (const item of items) { const promise = fn(item).then(result => { executing.delete(promise); return result; }); executing.add(promise); results.push(promise); if (executing.size >= concurrency) { await Promise.race(executing); } } return Promise.all(results);} // Process 100 URLs, max 5 concurrent requestsawait mapWithConcurrency(urls, fetchUrl, 5);Choosing the right combinator:
| Scenario | Combinator | Rationale |
|---|---|---|
| All data required | Promise.all | Need everything to proceed |
| Best-effort results | Promise.allSettled | Some failures acceptable |
| Timeout implementation | Promise.race | First to complete (success or fail) |
| Redundant sources | Promise.any | Need at least one success |
| Load balancing | Promise.race | Use fastest responder |
Promise.all doesn't make things parallel—it makes them concurrent. All the promises start immediately. They might run in parallel (if truly async I/O) or interleaved (on a single thread). The key is they don't wait for each other serially.
The future/promise abstraction appears across virtually all modern languages, though with varying names and semantics. Understanding these variations helps when working in polyglot environments.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// Java CompletableFuture (since Java 8) import java.util.concurrent.CompletableFuture;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors; public class FutureExample { public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(4); // Create a future that runs asynchronously CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> { // Runs in thread pool return fetchUserFromDatabase(userId); }, executor); // Chain operations (like .then()) future .thenApply(user -> user.toUpperCase()) // Sync transform .thenCompose(name -> fetchOrdersAsync(name)) // Async chain .thenAccept(orders -> { System.out.println("Orders: " + orders); }) .exceptionally(error -> { // Error handler (like .catch()) System.err.println("Failed: " + error.getMessage()); return null; }); // Combinators CompletableFuture<Void> all = CompletableFuture.allOf( fetchUserAsync(), fetchOrdersAsync(), fetchAnalyticsAsync() ); CompletableFuture<Object> any = CompletableFuture.anyOf( fetchFromMirror1(), fetchFromMirror2() ); executor.shutdown(); }} /* * Java CompletableFuture characteristics: * - Runs on explicit ExecutorService (thread pool) * - thenApply = sync transform (like .then with non-promise) * - thenCompose = async transform (like .then with promise) * - thenCombine = combine two futures * - exceptionally = catch * - handle = thenApply + exceptionally combined */JavaScript Promises are 'eager'—work starts immediately when created. Rust Futures are 'lazy'—nothing happens until awaited. This has implications for when effects occur and how to cancel work. Understanding this distinction prevents subtle bugs when moving between ecosystems.
Futures and Promises represent a paradigm shift in async programming—from passing callbacks to working with objects that represent eventual values.
What's next:
Promises solve many callback problems, but chains of .then() still don't look like synchronous code. The event loop is the mechanism that makes all async patterns work under the hood. Understanding the event loop reveals how JavaScript (and similar runtimes) achieve concurrency on a single thread.
You now understand Futures and Promises—the abstraction that transformed async programming from callback spaghetti into composable pipelines. Next, we'll explore the event loop: the runtime mechanism that orchestrates all async operations.