Asynchronous Programming
Futures and Promises
Read aloud is unavailable in this browser.
Values That Don't Exist Yet
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.
The Concept of Eventual Values
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?
- Attach handlers for success or failure (like callbacks, but as methods)
- Chain operations that depend on the result
- Combine multiple futures (wait for all, race, etc.)
- Pass it around like any other value
- Store it and check/use it later
- Return it from functions to propagate async-ness
| 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:
- 1988: MultiLisp introduces futures for parallel computation
- 1998: E programming language develops Promise pipelining
- 2007: Twisted (Python) and Dojo Toolkit use Deferred objects
- 2009: CommonJS Promises/A specification drafted
- 2012: Promises/A+ specification formalized
- 2015: JavaScript ES6 includes native Promises
- 2017: JavaScript async/await built on Promises
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.
Promise States and Transitions
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:
- A fulfilled promise stays fulfilled with the same value forever
- A rejected promise stays rejected with the same reason forever
- There's no 'reset' or 'retry' on the same promise object
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.
Creating and Resolving Promises
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.
Promise Chaining: Sequential Async Operations
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:
- Fulfills with the return value of the handler (if it returns a non-promise)
- Adopts the state of the returned promise (if handler returns a promise)
- Rejects if the handler throws an exception
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:
- Flat structure: No nesting, just a linear sequence of
.then()calls - Single error handling: One
.catch()at the end handles all errors - Clear data flow: Each step clearly receives output from the previous
- Easy to modify: Insert, remove, or reorder steps without restructuring
- Composable: Chain pieces can be extracted into functions that return promises
Returning 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.
Error Handling and Propagation
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.
Promise Combinators: Coordinating Multiple Promises
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.
Futures and Promises Across Languages
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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.