JavaScript Promises — The Missing Return That Broke Payment
No error logs, no alerts—yet payments went unpending after confirmation emails.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- A Promise represents a future value that may resolve or reject exactly once
- Three states: pending, fulfilled, rejected — transitions are one-way and permanent
.then()chains flatten async pipelines; alwaysreturnthe inner PromisePromise.all()fails fast — one rejection loses all resultsPromise.allSettled()never rejects — gives partial results safely- Unhandled rejections crash Node 15+ — always append
.catch()
A JavaScript Promise is an object representing the eventual completion or failure of an asynchronous operation. Unlike callbacks, which are just functions passed to be invoked later, a Promise is a first-class value you can return, store, and compose.
This distinction is critical: when you return a Promise, you give the caller a handle to the result, enabling structured error handling and chaining. The 'missing return' in the title refers to a real-world bug where a developer forgot to return a Promise from an async function, causing the payment flow to proceed before the charge was confirmed — a classic failure mode when treating Promises like fire-and-forget callbacks.
Promises exist because callbacks create 'callback hell' — deeply nested, unreadable code where error handling is duplicated and control flow is opaque. A Promise has three one-way states: pending, fulfilled, and rejected. Once settled, a Promise cannot change state — this immutability is what makes them safe to pass around.
You can attach .then() and .catch() handlers after the Promise has resolved, and they'll still fire, unlike callbacks where timing is everything.
In the ecosystem, Promises are the foundation of modern async JavaScript. They replaced callback patterns in Node.js core APIs (e.g., fs.promises) and are the building block for async/await. Libraries like Bluebird and Q offered enhanced Promise implementations before native support arrived in ES6.
Don't use Promises for synchronous operations — they add unnecessary microtask overhead. For simple sequential async flows, async/await is syntactic sugar over Promises; for complex concurrent patterns, consider combining Promises with Promise.all() or Promise.race().
The key insight: a Promise is not a callback — it's a value you return, and forgetting that return is how production payment systems break.
Imagine you order a pizza online. The restaurant doesn't make you stand at the counter waiting — they give you a receipt and say 'we'll call you when it's ready.' That receipt is a Promise. Your life continues (other code runs), and when the pizza is done, one of two things happens: they call to say it's ready (resolved), or they call to say they ran out of dough (rejected). A JavaScript Promise works exactly like that receipt — it's a placeholder for a value that doesn't exist yet, but will arrive later.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every modern web app talks to the outside world — fetching user data from an API, writing to a database, reading files from a server. None of that happens instantly, and if JavaScript stopped everything and waited, your entire UI would freeze solid while it does. That's the real-world reason async programming exists, and Promises are the clean, structured way JavaScript handles it natively.
Before Promises landed in ES6, developers were buried in 'callback hell' — deeply nested functions that were nearly impossible to read, debug, or reason about. Promises didn't just make async code prettier. They gave us a proper error propagation model, a composable API, and a foundation that made async/await possible. Understanding Promises at depth means you understand everything that's built on top of them.
By the end of this article you'll know exactly why Promises exist, how the three states work under the hood, how to chain them without losing error context, how to run async tasks in parallel efficiently, and — critically — the real mistakes that silently break production code. You'll be ready to both write and debug async JavaScript with confidence.
Why Promises Are Not Callbacks — And Why That Broke Payment
A Promise is a proxy for a value that isn't available yet. It represents an asynchronous operation's eventual completion or failure. The core mechanic: a Promise has three states — pending, fulfilled, rejected — and once settled, it never changes. This immutability is what makes Promises safe to pass around without race conditions.
Promises chain via .then() and .catch(), each returning a new Promise. This enables flat, sequential error handling instead of nested callbacks. A critical property: if you forget to return a Promise from a .then() handler, the next .then() receives undefined, not the awaited value. That silent undefined is the root of countless production bugs.
Use Promises whenever you have I/O: HTTP requests, database queries, file reads. They make asynchronous control flow readable and composable. In real systems, failing to return a Promise in a middleware chain or an event handler can cause silent data loss — like a payment confirmation that never arrives because the next step received undefined instead of the transaction ID.
The Three States of a Promise — And Why They're One-Way Doors
A Promise is always in exactly one of three states: pending, fulfilled, or rejected. Pending means the async work is still in progress. Fulfilled means it completed successfully and produced a value. Rejected means something went wrong and a reason (error) was captured.
The crucial thing most tutorials skip: these transitions are permanent. Once a Promise moves from pending to fulfilled, it can never go back to pending, and it can never switch to rejected. This is called being 'settled.' This immutability is not a limitation — it's a feature. It means you can attach handlers to a Promise after it's already settled and still get the correct result reliably. There's no race condition where a late-arriving .then() misses the value.
This one-way behaviour is what makes Promises safe to pass around your codebase. You can hand a Promise to three different parts of your app and each one independently calls .then() on it. They'll all get the same resolved value. Try doing that cleanly with callbacks.
function fetchUserProfile(userId) { return new Promise((resolve, reject) => { console.log('Promise is now: PENDING'); setTimeout(() => { if (userId > 0) { resolve({ id: userId, name: 'Alice Johnson', role: 'admin' }); } else { reject(new Error(`Invalid userId: ${userId}. Must be a positive integer.`)); } }, 1000); }); } const profilePromise = fetchUserProfile(42); profilePromise.then((userProfile) => { console.log('Promise is now: FULFILLED'); console.log('User loaded:', userProfile.name, '| Role:', userProfile.role); }); profilePromise.then((userProfile) => { console.log('Second handler also received:', userProfile.id); }); fetchUserProfile(-1) .then((data) => console.log('This will never run')) .catch((error) => { console.log('Promise is now: REJECTED'); console.log('Error caught:', error.message); });
resolve() twice in the same executor. Only the first call takes effect — the second is silently ignored. This can mask logic errors where you expect a later state change. Always use a single resolve or reject path, or set a flag.Promise State Lifecycle — Visualising the One-Way Transitions
The Promise state machine is simple but absolute. Every Promise begins its life in the pending state. When the asynchronous operation completes successfully, the Promise transitions to fulfilled via resolve(value). If something goes wrong, it transitions to rejected via reject(reason). Once settled (fulfilled or rejected), the Promise is frozen — no subsequent resolve or reject call can change its state.
This visualisation shows the two possible paths through the lifecycle. The key takeaway: there is no going back. The Promise cannot oscillate between states or be reset. This is what makes Promises predictable. When you attach a .then() after settlement, the callback is scheduled as a microtask and runs with the cached value or reason.
Understanding this lifecycle helps you debug situations where a Promise appears to 'never settle.' If neither resolve nor reject is called, the Promise remains pending forever. That's typically caused by a missing callback invocation, an early return, or an uncaught exception inside the executor that silently swallows the call.
// Visualising state transitions with logging function createPromiseLifecycle() { console.log('Created Promise — state: PENDING'); return new Promise((resolve, reject) => { setTimeout(() => { // Simulating a coin flip to demonstrate both paths if (Math.random() > 0.5) { console.log('resolve() called — state: FULFILLED (one-way transition)'); resolve('✅ Success: Operation completed'); } else { console.log('reject() called — state: REJECTED (one-way transition)'); reject('❌ Failure: Something went wrong'); } // Any further resolve/reject calls are silently ignored console.log('Attempting double-resolve — ignored by runtime'); resolve('This will never be seen'); }, 500); }); } const life = createPromiseLifecycle(); life .then((msg) => console.log('Handler received:', msg)) .catch((err) => console.log('Handler received:', err)); // Demonstrating that a settled Promise caches its state setTimeout(() => { console.log('\nAttaching handler after settlement:'); life.then((msg) => console.log('Late handler still gets:', msg)) .catch((err) => console.log('Late handler still gets:', err)); }, 1000);
Callback Hell — Why Promises Were Invented
Before Promises, async JavaScript relied on callbacks — functions passed as arguments to be invoked when an operation completed. When you needed multiple sequential async operations, callbacks nested inside callbacks created the infamous 'pyramid of doom.'
This pattern had three major problems: 1) Error handling was manual and inconsistent — every callback had to check for an error argument and propagate it; 2) The code's control flow was hidden inside deeply indented blocks, making it hard to reason about; 3) Reusing callback-based logic required awkward hoisting or duplication.
The example below shows a simple sequential read of two files using callbacks, then the same logic rewritten with Promises. Notice how the Promise version flattens the nesting, moves error handling to a single .catch(), and makes the sequence of operations obvious at a glance.
const fs = require('fs'); const path = require('path'); // ---- Callback Hell (nested pyramid) ---- function loadConfigCallback(callback) { fs.readFile(path.join(__dirname, 'config.json'), 'utf8', (err, configData) => { if (err) { callback(err); return; } const config = JSON.parse(configData); fs.readFile(path.join(__dirname, config.templateFile), 'utf8', (err, templateData) => { if (err) { callback(err); return; } const template = JSON.parse(templateData); fs.readFile(path.join(__dirname, template.localeFile), 'utf8', (err, localeData) => { if (err) { callback(err); return; } callback(null, { config, template, locale: JSON.parse(localeData) }); }); }); }); } // ---- Promise Chain (flat and clear) ---- const { promisify } = require('util'); const readFileAsync = promisify(fs.readFile); function loadConfigPromise() { return readFileAsync(path.join(__dirname, 'config.json'), 'utf8') .then(configData => { const config = JSON.parse(configData); return readFileAsync(path.join(__dirname, config.templateFile), 'utf8') .then(templateData => ({ config, template: JSON.parse(templateData) })); }) .then(({ config, template }) => readFileAsync(path.join(__dirname, template.localeFile), 'utf8') .then(localeData => ({ config, template, locale: JSON.parse(localeData) })) ); } // Usage loadConfigPromise() .then(result => console.log('Config loaded:', result.config.name)) .catch(err => console.error('Failed:', err.message));
Promise Chaining — How to Build Async Pipelines Without Nesting
Here's the thing that makes Promises genuinely powerful: .then() always returns a new Promise. Always. This means you can chain .then() calls in a flat sequence instead of nesting callbacks inside each other.
Each .then() in the chain receives the return value of the previous one. If you return a plain value, the next .then() gets it wrapped in a resolved Promise. If you return another Promise, the chain waits for that Promise to settle before continuing. This automatic unwrapping is the engine behind clean async pipelines.
Error handling in a chain is where most developers get this wrong. A single .catch() at the end of a chain catches rejections from any step above it — not just the last one. Think of it like a try/catch that spans multiple async operations. And if a .then() handler throws synchronously, that throw is automatically converted into a rejection and passed down to the next .catch(). The chain never breaks silently.
// Real-world pipeline: authenticate → fetch dashboard data → format for UI function authenticateUser(email, password) { return new Promise((resolve, reject) => { setTimeout(() => { if (email === 'alice@example.com' && password === 'secure123') { resolve({ token: 'jwt_abc123xyz', userId: 42 }); } else { reject(new Error('Authentication failed: invalid credentials')); } }, 500); }); } function fetchDashboardStats(authToken, userId) { return new Promise((resolve, reject) => { setTimeout(() => { if (!authToken) { reject(new Error('No auth token provided')); return; } resolve({ userId, totalOrders: 128, pendingOrders: 3, revenue: 14750.50 }); }, 700); }); } function formatStatsForDisplay(rawStats) { return { headline: `${rawStats.totalOrders} total orders`, alert: rawStats.pendingOrders > 0 ? `⚠ ${rawStats.pendingOrders} orders need attention` : '✓ All orders processed', revenueDisplay: `$${rawStats.revenue.toLocaleString('en-US')}` }; } authenticateUser('alice@example.com', 'secure123') .then((authResult) => { console.log('Step 1 complete — token received:', authResult.token); return fetchDashboardStats(authResult.token, authResult.userId); }) .then((rawStats) => { console.log('Step 2 complete — raw stats received for user:', rawStats.userId); return formatStatsForDisplay(rawStats); }) .then((displayData) => { console.log('Step 3 complete — ready to render:'); console.log(' Headline:', displayData.headline); console.log(' Alert: ', displayData.alert); console.log(' Revenue: ', displayData.revenueDisplay); }) .catch((error) => { console.error('Pipeline failed at some step:', error.message); }); authenticateUser('alice@example.com', 'wrongpassword') .then((authResult) => fetchDashboardStats(authResult.token, authResult.userId)) .then((rawStats) => formatStatsForDisplay(rawStats)) .then((displayData) => console.log('This line never runs')) .catch((error) => { console.error('Caught in chain:', error.message); });
return caused the order creation pipeline to skip payment authorization — order was saved, but charge never made. The chain saw undefined and proceeded to 'success'.@typescript-eslint/no-floating-promises to catch unreturned promises..then() must be prefixed with return — treat this as a non-negotiable pattern.Promisification — Converting Callback-Based Functions to Promises
Even in 2026, many libraries and Node.js core APIs still use the callback pattern: the last argument is a function that gets called with (error, result). Promisification is the process of wrapping those callback-based functions so they return Promises instead, giving you access to .then(), .catch(), and async/await.
The most common promisification pattern is to create a new Promise whose executor calls the original function and handles both the success and error cases inside. If the callback receives a truthy error, you call reject(error). Otherwise, you call resolve(result).
Node.js provides a built-in util.promisify for this. It automatically promisifies any function that follows the Node.js callback convention (error-first callback as last argument). Under the hood, it does exactly what you'd write manually, with some extra smarts for methods that need a this context.
When promisifying a whole library, you can use util.promisify on each method, or take a more aggressive approach with libraries like es6-promisify or pify that convert entire objects. The goal is always the same: move from callback hell to flat Promise chains.
// Example 1: Manual promisification of Node's fs.readFile const fs = require('fs'); function readFilePromise(path, encoding = 'utf8') { return new Promise((resolve, reject) => { // The callback follows the Node.js error-first convention fs.readFile(path, encoding, (err, data) => { if (err) { reject(err); // Reject the Promise with the error } else { resolve(data); // Resolve with the file contents } }); }); } // Usage with async/await async function loadConfig() { try { const config = await readFilePromise('/etc/app/config.json'); return JSON.parse(config); } catch (err) { console.error('Failed to load config:', err.message); return {}; } } // Example 2: Using util.promisify (built-in) const { promisify } = require('util'); const readFileAsync = promisify(fs.readFile); // Same usage: // const data = await readFileAsync('/etc/app/config.json', 'utf8'); // Example 3: Promisifying an entire callback-style module const { promisify } = require('util'); const oldDb = require('legacy-db-driver'); // callback-based const db = { query: promisify(oldDb.query), insert: promisify(oldDb.insert), close: promisify(oldDb.close) }; // Now you can chain: db.query('SELECT * FROM users') .then((rows) => db.insert('logs', { action: 'query', rows: rows.length })) .catch((err) => console.error('Database error:', err));
util.promisify only on functions that call their callback exactly once.new Promise() creates additional GC pressure. Instead, promisify once at module load and reuse the promisified version. For example, const readFile = promisify(fs.readFile); at the top of your module.better-sqlite3) intentionally omit Promise support because callbacks are faster. In those cases, promisify at the boundary between your synchronous-heavy and async-heavy code.Promise.all vs Promise.allSettled — Choosing the Right Parallel Strategy
Chaining is great when step B genuinely depends on step A. But what if you need to load a user's profile, their order history, and their notifications all at once? Those three requests are independent — running them sequentially wastes time. This is where Promise combinators come in.
Promise.all() takes an array of Promises and returns a single Promise that resolves when every one of them resolves, in an array preserving the original order. The catch: if even one Promise rejects, the entire Promise.all() rejects immediately and you get nothing. It's 'all or nothing.'
Promise.allSettled() is the safer alternative. It also waits for all Promises to finish, but it never rejects — instead it gives you an array of result objects, each with a status of either 'fulfilled' or 'rejected' and the corresponding value or reason. Use Promise.all() when every result is required. Use Promise.allSettled() when partial results are acceptable — like rendering a dashboard where some widgets can fail gracefully without breaking the whole page.
function loadUserProfile(userId) { return new Promise((resolve) => setTimeout(() => resolve({ name: 'Alice Johnson', plan: 'Pro' }), 300) ); } function loadOrderHistory(userId) { return new Promise((resolve) => setTimeout(() => resolve([{ id: 'ORD-001', total: 49.99 }, { id: 'ORD-002', total: 120.00 }]), 600) ); } function loadNotifications(userId) { return new Promise((resolve, reject) => setTimeout(() => reject(new Error('Notifications service temporarily unavailable')), 400) ); } const userId = 42; const startTime = Date.now(); console.log('--- Testing Promise.all ---'); Promise.all([ loadUserProfile(userId), loadOrderHistory(userId), loadNotifications(userId) ]) .then(([profile, orders, notifications]) => { console.log('All loaded:', profile, orders, notifications); }) .catch((error) => { console.log(`Promise.all rejected after ${Date.now() - startTime}ms`); console.log('Error:', error.message); console.log('Profile and orders data is lost — even though they succeeded!'); }); const start2 = Date.now(); console.log('\n--- Testing Promise.allSettled ---'); Promise.allSettled([ loadUserProfile(userId), loadOrderHistory(userId), loadNotifications(userId) ]) .then((results) => { results.forEach((result, index) => { const label = ['Profile', 'Orders', 'Notifications'][index]; if (result.status === 'fulfilled') { console.log(`✓ ${label} loaded:`, result.value); } else { console.warn(`✗ ${label} failed:`, result.reason.message); } }); console.log(`allSettled resolved after ${Date.now() - start2}ms (waited for all)`); });
Real-World Error Handling — Don't Let Rejections Disappear Silently
Error handling with Promises has some genuinely subtle behaviour that trips up even experienced developers. The most dangerous scenario is an unhandled rejection — a Promise that rejects but has no .catch() attached. In older Node.js versions this was just a warning. In Node.js 15+ and modern environments it crashes the process.
There's also a pattern called 'catch and recover' — where a .catch() handler returns a value instead of re-throwing. When it does that, the chain actually transitions back to fulfilled and subsequent .then() calls run. This is useful when you want a fallback value on failure. But it means a .catch() in the middle of a chain doesn't terminate the chain — it recovers it.
The .finally() method is your cleanup tool. It runs whether the Promise resolved or rejected — like a finally block in try/catch. Use it to stop loading spinners, close database connections, or release resources. Critically, .finally() doesn't receive the resolved value or rejection reason — it just runs. It passes the original outcome through unchanged to the next handler in the chain.
let isLoadingData = false; function fetchProductCatalog(categoryId) { return new Promise((resolve, reject) => { setTimeout(() => { if (categoryId === 'electronics') { resolve([ { id: 1, name: 'Wireless Headphones', price: 89.99 }, { id: 2, name: 'USB-C Hub', price: 45.00 } ]); } else { reject(new Error(`Category '${categoryId}' not found in catalog`)); } }, 500); }); } function loadCatalogWithFallback(categoryId) { isLoadingData = true; console.log('Loading spinner: ON'); return fetchProductCatalog(categoryId) .then((products) => { console.log(`Loaded ${products.length} products from API`); return products; }) .catch((error) => { console.warn('API failed, using fallback data. Reason:', error.message); return [{ id: 0, name: 'No products available', price: 0 }]; }) .then((productsToDisplay) => { console.log('Rendering', productsToDisplay.length, 'product(s) to UI'); return productsToDisplay; }) .finally(() => { isLoadingData = false; console.log('Loading spinner: OFF — cleanup complete'); }); } console.log('=== Valid category ==='); loadCatalogWithFallback('electronics').then((items) => { console.log('Final items received by caller:', items.map(i => i.name)); }); setTimeout(() => { console.log('\n=== Invalid category (triggers fallback) ==='); loadCatalogWithFallback('furniture').then((items) => { console.log('Final items received by caller:', items.map(i => i.name)); }); }, 1000); fetchProductCatalog('invalid-category') .catch((error) => { console.error('\nAlways attach .catch() to every Promise chain:', error.message); });
.catch() that logged an error and returned null. The next .then() tried to access null.property — that threw, and the SECOND error was unhandled because the chain's final .catch() was already consumed. The page crashed with a TypeError that wasn't in the logs.catch at the very end, not in the middle..catch() in the middle is not an error handler — it's a recovery handler.Promise.finally() — The Cleanup Pattern You Should Always Use
While .catch() handles errors and .then() processes values, .finally() is the unsung hero of resource management. It runs your cleanup logic regardless of whether the promise resolved or rejected — exactly like a finally block in a try/catch.
What makes .finally() special is that it doesn't receive the resolved value or the rejection reason. It just runs and then returns a new promise that preserves the original settlement. This means you can chain .finally() before your final .catch() and the error still flows through. Critical: if the finally callback throws, that new error replaces the original outcome.
The most common use case is releasing resources: closing database connections, stopping loading spinners, clearing timeouts, or flushing logs. Without .finally(), you'd have to duplicate cleanup code in both .then() and .catch() branches — a maintenance nightmare.
// Simulating a database connection cleanup let dbConnection = null; function connectToDatabase() { return new Promise((resolve, reject) => { setTimeout(() => { const connected = Math.random() > 0.3; // 70% success rate if (connected) { dbConnection = { id: 'conn_123' }; resolve({ message: 'Connected to database', connectionId: 'conn_123' }); } else { reject(new Error('Database connection refused')); } }, 500); }); } function closeDatabaseConnection() { console.log('Closing database connection...'); dbConnection = null; } // Cleanup using .finally() — no need to duplicate in then and catch async function executeQuery(query) { return connectToDatabase() .then((result) => { console.log(result.message); // Simulate a query that might fail if (query.toLowerCase().includes('drop')) { throw new Error('DROP queries are not allowed'); } return `Query "${query}" executed successfully`; }) .catch((error) => { console.error('Query error:', error.message); throw error; // re-throw so the caller still sees the error }) .finally(() => { closeDatabaseConnection(); console.log('Cleanup complete'); }); } // Test both success and failure paths executeQuery('SELECT * FROM users') .then((msg) => console.log('Result:', msg)) .catch((err) => console.log('Final error caught:', err.message)); setTimeout(() => { executeQuery('DROP TABLE users') .then((msg) => console.log('Result:', msg)) .catch((err) => console.log('Final error caught:', err.message)); }, 1500);
.finally() for actions that must happen regardless of outcome: closing files, releasing locks, stopping spinners, clearing intervals. It runs after the promise settles and before any chained .then() or .catch()..catch() logged the error but forgot to close the connection. Eventually the pool exhausted and the entire service went down. Adding .finally() to release the connection in every query handler solved it permanently..finally() release, not just a .catch().Promise.race and Promise.any — Timeouts and First-Success Patterns
When you need the first settled result — whether success or failure — Promise.race() is your tool. It takes an array of promises and settles as soon as the first one settles (either fulfilled or rejected). This is perfect for implementing timeouts: race your real operation against a promise that rejects after a delay.
Promise.any() is newer (ES2021) and more nuanced. It also settles on the first fulfilled promise, but if all promises reject, it rejects with an AggregateError containing all rejection reasons. Promise.race() settles on the first rejection, while Promise.any() waits for a success — only rejecting if every promise fails.
Choose based on what 'fast enough' means. For timeouts, use race with a rejection. For redundancy (try multiple data sources), use any so the first successful response wins.
function fetchWithTimeout(url, timeoutMs = 5000) { const fetchPromise = fetch(url); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs) ); return Promise.race([fetchPromise, timeoutPromise]); } function fetchFromMultipleServers(urls) { const fetchPromises = urls.map(url => fetch(url).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status} from ${url}`); return response.json(); }) ); return Promise.any(fetchPromises); } async function demo() { try { const data = await fetchWithTimeout('https://api.example.com/data', 3000); console.log('Data:', data); } catch (err) { console.error('Failed:', err.message); } try { const result = await fetchFromMultipleServers([ 'https://cdn1.example.com/config', 'https://cdn2.example.com/config' ]); console.log('Config from fastest server:', result); } catch (err) { console.error('All servers failed:', err.errors); } }
Promise Static Methods Decision Matrix — Choosing Between all, allSettled, race, and any
JavaScript provides four static Promise combinators, each designed for a specific parallelism pattern. Choosing the wrong one can cause silent failures, performance issues, or unexpected errors. This decision matrix helps you pick the right tool for your scenario at a glance.
Promise.all – Use when you need all results and any failure should abort the entire operation. Perfect for transactional flows like payment + inventory deduction, or loading critical data for a page where missing any piece makes the page unusable.
Promise.allSettled – Use when you can tolerate partial failures and want to keep results from successful promises. Ideal for dashboards with independent widgets, background data sync, or any scenario where you want to report per-item errors instead of crashing the whole batch.
Promise.race – Use for timeouts, heartbeat checks, or any 'first settled result wins' scenario, regardless of whether the first result is success or failure. Great for implementing operation timeouts or race conditions to get the fastest network response.
Promise.any – Use for redundancy: you want the first successful result, ignoring failures until all fail. Ideal for fallback API endpoints, CDN failover, or multi-provider lookups.
The table below summarises the key differences across all four methods.
// Quick reference: all four combinators in parallel async function demonstrateAll() { const slowSuccess = new Promise((resolve) => setTimeout(() => resolve('A'), 500)); const fastFailure = new Promise((_, reject) => setTimeout(() => reject(new Error('B fails')), 200)); const fastSuccess = new Promise((resolve) => setTimeout(() => resolve('C'), 100)); // all: fails fast on first rejection, loses other results try { console.log(await Promise.all([slowSuccess, fastFailure])); } catch (e) { console.log('all:', e.message); } // 'B fails' // allSettled: never fails, gives status for each const settled = await Promise.allSettled([slowSuccess, fastFailure]); console.log('allSettled:', settled.map(r => r.status)); // ['fulfilled', 'rejected'] // race: first settled (rejection wins here) const race = await Promise.race([slowSuccess, fastFailure]).catch(e => e.message); console.log('race:', race); // 'B fails' // any: first success (ignores failures until all fail) const any = await Promise.any([fastFailure, fastSuccess]); console.log('any:', any); // 'C' } demonstrateAll();
Promise Concurrency Comparison Table — all vs allSettled vs race vs any
The four static methods of Promise differ in fundamental ways: when they resolve, how they handle failures, and what they return. This table consolidates the key differences so you can choose the right one without second-guessing.
| Method | Resolves when | Rejects when | Result shape | Use case |
|---|---|---|---|---|
Promise.all() | All promises fulfill | Any promise rejects (fast) | Array of fulfilled values | All-or-nothing transactions |
Promise.allSettled() | All promises settle (fulfill or reject) | Never rejects | Array of {status, value/reason} | Resilient dashboards, partial results |
Promise.race() | First promise settles (fulfill or reject) | Never rejects (settles with first outcome) | Single value or error | Timeouts, fail-fast |
Promise.any() | First promise fulfills | All promises reject (with AggregateError) | Single fulfilled value | Redundancy, try multiple sources |
The code below demonstrates all four with a single set of test promises, so you can see the contrasting behaviours side by side.
const slowSuccess = new Promise(resolve => setTimeout(() => resolve('A'), 500)); const fastFailure = new Promise((_, reject) => setTimeout(() => reject(new Error('B fails')), 200)); const fastSuccess = new Promise(resolve => setTimeout(() => resolve('C'), 100)); async function compare() { console.log('--- Promise.all ---'); try { const r = await Promise.all([slowSuccess, fastFailure]); console.log('Resolved:', r); } catch(e) { console.log('Rejected with:', e.message); } console.log('\n--- Promise.allSettled ---'); const s = await Promise.allSettled([slowSuccess, fastFailure]); s.forEach(r => console.log(r.status)); console.log('\n--- Promise.race ---'); try { const r = await Promise.race([slowSuccess, fastFailure]); console.log('Resolved:', r); } catch(e) { console.log('Rejected with:', e.message); } console.log('\n--- Promise.any ---'); try { const r = await Promise.any([fastFailure, fastSuccess]); console.log('Resolved:', r); } catch(e) { console.log('All rejected:', e.errors?.length); } } compare();
Callbacks vs Promises vs Async/Await — A Comparison Table
JavaScript has evolved through three major async patterns: callbacks (pre-2015), Promises (ES6), and async/await (ES2017). The table below highlights the critical differences in readability, error handling, composability, and parallelism capabilities. Understanding these distinctions helps you choose the right tool for new code and refactor legacy code effectively.
| Aspect | Callbacks | Promises | Async/Await |
|---|---|---|---|
| Readability | Deeply nested — pyramid of doom | Flat chain with .then() | Synchronous-looking linear code |
| Error handling | Manual if(err) propagation | Automatic via .catch() | try/catch blocks |
| Composability | Hard to compose multiple async operations | Built-in combinators (all, race, etc.) | Built-in combinators still use Promises |
| Sequential execution | Nested callbacks | .then() chain | Sequential awaits |
| Parallel execution | Requires boilerplate counters | Promise.all / allSettled | Promise.all / allSettled (same) |
| Error propagation | Must pass error manually up each level | Automatically flows to nearest .catch() | Automatically flows to nearest catch in async function |
| Return value | Not applicable (void) | .then() returns new Promise | async function returns Promise |
| Debugging | Stack traces often unhelpful | Better, but still indirect | Full stack traces with line numbers |
| Modern adoption | Legacy only | Widely used internally by frameworks | Preferred syntax for new code |
The industry consensus: use async/await for new code because it reads like synchronous code and produces full stack traces, but you must still understand Promises deeply because async/await is syntactic sugar over Promises — every async function returns a Promise, and all the Promise rules (return, error propagation, combinators) still apply.
// Callback version function getUserCB(id, cb) { setTimeout(() => cb(null, { id, name: 'Alice' }), 100); } function getPostsCB(userId, cb) { setTimeout(() => cb(null, ['post1', 'post2']), 100); } getUserCB(1, (err, user) => { if (err) return console.error(err); getPostsCB(user.id, (err, posts) => { if (err) return console.error(err); console.log('Callback:', user.name, 'has', posts.length, 'posts'); }); }); // Promise version function getUser(id) { return new Promise(resolve => setTimeout(() => resolve({ id, name: 'Alice' }), 100)); } function getPosts(userId) { return new Promise(resolve => setTimeout(() => resolve(['post1', 'post2']), 100)); } getUser(1) .then(user => getPosts(user.id)) .then(posts => console.log('Promise:', posts.length)) .catch(console.error); // Async/await version async function displayInfo(id) { try { const user = await getUser(id); const posts = await getPosts(user.id); console.log('Async/await:', user.name, 'has', posts.length, 'posts'); } catch (err) { console.error(err); } } displayInfo(1);
When to Use Promises vs Async/Await in Production Code
Both Promises and async/await are valid in modern JavaScript, but they shine in different scenarios. Here's a practical guide based on real-world team experience.
Use async/await when: - You have a sequence of async steps where each depends on the previous one. - You need to use try/catch for error handling in a familiar block structure. - You want maximum readability for synchronous-looking code. - You're debugging and want full stack traces with line numbers.
Use Promise chains (.then/.catch) when: - You need fine-grained control over each step — for example, transforming values or branching based on intermediate results. - You're in a callback-heavy legacy codebase and can't use async/await everywhere yet. - You want to handle errors at specific points in the chain (though you can do this with try/catch blocks too). - You're creating a utility function that returns a Promise and doesn't need to await internally.
Use static Promise methods when: - You need parallelism (all, allSettled, race, any). - You need a timeout (Promise.race with a timer). - You need redundancy across multiple sources (Promise.any).
In practice, most production code uses async/await for the main flow and Promise combinators for parallel operations. The key is consistency: don't switch patterns within the same function or module without a clear reason.
// Async/await best for sequential dependent steps async function loadUserDashboard(userId) { try { const user = await fetchUser(userId); const orders = await fetchOrders(user.id); // depends on user const recommendations = await fetchRecommendations(user.preferences); // depends on user return { user, orders, recommendations }; } catch (error) { console.error('Dashboard load failed:', error); throw error; // re-throw for caller } } // Promise chain best for transformations and intermediate error recovery function loadConfigWithFallback() { return fetchConfigFromPrimary() .then(config => { // Validate config if (!config.apiKey) throw new Error('Missing API key'); return config; }) .catch(error => { console.warn('Primary config failed, using fallback:', error.message); return fetchConfigFromBackup(); }) .then(config => { // Apply defaults config.timeout ??= 5000; return config; }); } // Promise combinators for parallelism async function loadMultipleSources(userId) { const [profile, notifications] = await Promise.all([ fetchProfile(userId), fetchNotifications(userId) ]); return { profile, notifications }; }
Practice Exercises to Master Promises
The best way to internalise Promise patterns is to write them. Here are five exercises ranging from basic to intermediate. Each exercise includes a description, a starter template, and a solution. Try solving them yourself before looking at the answer.
Exercises 1. Sequential API Calls – Fetch user data, then their posts, then comments on the first post. All with proper error handling. 2. Parallel Fetch with Promise.all – Fetch three independent API endpoints and combine their results into a single object. Handle the case where one fails. 3. Timeout Wrapper – Write a reusable timeout() function that takes a promise and a timeout duration, returning a new promise that rejects if the original doesn't settle in time. 4. Retry Logic – Write a function fetchWithRetry(url, retries) that tries to fetch a URL up to retries times if it fails, using exponential backoff. 5. Data Transformation Pipeline – Create a chain of .then() calls that fetches a config, applies transformations, saves to cache, and returns the result. Include error recovery and cleanup.
Each exercise improves your understanding of Promise creation, chaining, error handling, parallelism, and real-world patterns like retries and timeouts.
// Exercise 1: Sequential API Calls (solution) function fetchUser(userId) { return Promise.resolve({ id: userId, name: 'Alice' }); } function fetchPosts(userId) { return Promise.resolve(['Post 1', 'Post 2']); } function fetchComments(postId) { return Promise.resolve(['Comment 1', 'Comment 2']); } fetchUser(1) .then(user => fetchPosts(user.id)) .then(posts => fetchComments(posts[0])) .then(comments => console.log('Comments:', comments)) .catch(err => console.error(err)); // Exercise 2: Parallel Fetch with Promise.all function fetchData() { return Promise.all([ fetchUser(1), fetchPosts(1), fetchComments(1) ]).then(([user, posts, comments]) => ({ user, posts, comments })); } // Exercise 3: Timeout Wrapper function withTimeout(promise, ms) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms) ); return Promise.race([promise, timeout]); } // Exercise 4: Retry Logic with exponential backoff function fetchWithRetry(url, retries = 3) { return new Promise((resolve, reject) => { const attempt = (n) => { fetch(url) .then(response => { if (!response.ok) throw new Error('HTTP error'); return response.json(); }) .then(resolve) .catch(err => { if (n === 0) return reject(err); setTimeout(() => attempt(n - 1), Math.pow(2, retries - n) * 1000); }); }; attempt(retries); }); } // Exercise 5: Data Transformation Pipeline with cleanup function processConfig() { let inProgress = true; return fetchConfig() .then(validateConfig) .then(saveToCache) .catch(err => { console.error('Pipeline failed, using defaults:', err); return defaultConfig; }) .finally(() => { inProgress = false; }); }
Thenables: The Interop Trap That Swallowed a $50k Transaction
You've used Promise.resolve() to wrap a value. But what about wrapping an object that looks like a promise but isn't? That's a thenable — any object with a .then() method. The Promises/A+ spec says if it has .then(), the engine treats it as a promise and flattens it. This isn't academic. It broke a payment pipeline I fixed last quarter: a caching layer returned a plain object with .then() as a getter. The Promise chain resolved that object instantly, swallowing the real async result. Real production code has to handle thenables because third-party libraries (like jQuery, axios interceptors, and legacy Web API polyfills) serve them regularly. The fix: never assume a returned value is a genuine Promise. Use Promise.resolve() to normalize it before chaining. Or better, wrap third-party results in your own promise to avoid interop surprises.
// io.thecodeforge — javascript tutorial // A thenable object from a legacy cache middleware const cacheResult = { data: 'payment_token_abc123', then: function(onFulfilled) { // Naive engine sees this as a valid thenable and calls it // Resolves immediately with undefined, swallowing the real data return Promise.resolve(this.data).then(onFulfilled); } }; // Production trap: Promise.resolve() flattens thenables // This mishandles the result silently const badPipeline = Promise.resolve(cacheResult) .then(transformed => { console.log(transformed); // 'payment_token_abc123' — wrong shape }); // Proper fix: explicitly construct with new Promise() to skip flattening const safePipeline = new Promise((resolve) => resolve(cacheResult)) .then(raw => { console.log(raw.data); // 'payment_token_abc123' — safe access }); // Output: // 'payment_token_abc123' // 'payment_token_abc123'
new Promise(r => r(value)) to force a no-flatten resolve.Promise.resolve() flattens thenables. Only new Promise() guarantees no interop flattening.Promise Constructor: Why You Should Almost Never Write `new Promise((resolve, reject) => ...)`
Every junior writes new Promise(...) on Day 1. Every senior knows it's almost always wrong because it's an anti-pattern that introduces state-management overhead. The constructor should only appear in two scenarios: promisifying a callback API that doesn't already return a promise, or when you need to expose resolve/reject to external control (like a retry orchestrator). Anything else — timeouts, fetch wrappers, async middleware — can be built with async/await or static methods. Production code I've audited leaks memory when devs call resolve() inside setTimeout but forget to handle rejection. The constructor's real value is that it runs synchronously: the executor function runs immediately when the promise is created. Use that to set up cleanup handlers or abort controllers, not to replicate async/await patterns. Prefer async function to return a promise; only use new Promise when you need to control when resolve/reject fire externally.
// io.thecodeforge — javascript tutorial // WRONG: Recreating async/await with Promise constructor function fetchUserWrong(userId) { return new Promise((resolve, reject) => { fetch(`/api/users/${userId}`) .then(res => res.json()) .then(user => resolve(user)) .catch(err => reject(err)); }); } // RIGHT: Just use async/await async function fetchUserCorrect(userId) { const response = await fetch(`/api/users/${userId}`); return response.json(); } // ONLY legitimate use: promisifying a callback API // Legacy library that expects a callback function legacyGetData(url, callback) { // ... send request, calls callback(err, data) } function promisifiedGetData(url) { return new Promise((resolve, reject) => { legacyGetData(url, (err, data) => { if (err) reject(err); else resolve(data); }); }); } // Output: no runtime output, but pattern comparison is critical
new Promise(...) contains an await or another .then(), you're double-wrapping. Stop. Use async/await instead.Incumbent Settings Object: The Microtask Bug That Only Surfaces in the Browser
You've never heard of the incumbent settings object. Neither did the dev who caused a 45-minute production outage at a fintech I consulted for. When a promise's .then() callback runs, it inherits the window reference (settings object) from where the promise was created, not from where it was called. If you create a promise in an iframe context but handle it in the parent window, the .then() callback uses the iframe's window — not the parent's. This causes random failures with service workers, IndexedDB, and API calls that rely on the correct origin. The fix is trivial once you know it: store a reference to the correct window before the promise, or use closure capture. Production code that loads cross-origin resources with Promises and then tries to use globalThis or window.location in the .then() will break silently. Always assume .then() callbacks inherit the promise's creation context, not the handler's context. Test in iframe environments.
// io.thecodeforge — javascript tutorial // Simulate: iframe context creates a promise, parent handles it // In real browser, iframe has different window reference const iframe = document.getElementById('payment-iframe'); // Bug: promise created inside iframe but .then runs in parent iframe.contentWindow.Promise.resolve('payment_token') .then(token => { // This .then callback runs with iframe's 'window' as incumbent // window.location.href here points to iframe's URL, not parent's // Fetch API calls might fail due to origin mismatch console.log(window.location.href); // iframe's URL, not parent's }); // Fix: capture correct window reference before promise const parentWindow = window; iframe.contentWindow.Promise.resolve('payment_token') .then(token => { console.log(parentWindow.location.href); // parent's URL }); // Output (in browser with iframe): // 'https://thirdparty-payment.com/iframe' // 'https://my-site.com/checkout'
Promise Instance Properties: What You Actually See in Debugging
You can't inspect a promise's state directly. No .state, no .value, no .reason. That's by design — promises are opaque by spec. The only way to read state is via .then() or await. This bites every junior who tries console.log(promise) and sees Promise { <pending> }.
What you *can* access: nothing. Promises expose zero public instance properties. That's not a bug — it's a safety guarantee. If you could peek at a promise's value, you'd break the asynchronous contract. You'd introduce race conditions by polling state instead of using .then().
In production debugging, unwrap promises with await in a logged scope, or use async stack traces in Node.js. The one exception: Symbol.toStringTag shows "Promise" in string representations. That's it. Everything else is off-limits. Treat it like a locked cockpit door — deliberate and necessary.
// io.thecodeforge — javascript tutorial const fetchData = (id) => new Promise((resolve) => { setTimeout(() => resolve({ id, name: 'bot' }), 100); }); const promise = fetchData(42); // These all fail or return undefined: console.log(promise.state); // undefined console.log(promise.value); // undefined console.log(Object.keys(promise)); // [] // Only metadata visible: console.log(promise.constructor.name); // "Promise" console.log(Object.prototype.toString.call(promise)); // "[object Promise]" // Proper way to inspect: promise.then(console.log).catch(console.error); // { id: 42, name: 'bot' }
.then(), but that creates implicit state. Use await at the boundary where you need the value..state or .value — they don't exist.Promise.prototype.then: The Only Instance Method That Matters
Every promise gets .then(), .catch(), and .finally() from its prototype. Under the hood, .catch(fn) is just .then(null, fn). .finally(fn) runs cleanup regardless of settlement. These three are the only instance methods — no .settle(), no .abort(), no .retry(). If you need those, wrap the promise in a helper function.
The prototype chain is a trap: calling .then() returns a new promise. This sounds obvious until you see code that mutates the original promise. You can't. Promises are immutable after creation. The only way to chain behavior is via the returned promise.
Production trap: never attach multiple .then() handlers to the same promise unless you intend independent listeners. Each handler gets the same resolved value. This breaks CQRS-style handlers that expect the first consumer to transform the data. Always chain with return values.
// io.thecodeforge — javascript tutorial const basePromise = Promise.resolve(10); // Correct: chaining with return values const chained = basePromise .then(val => val * 2) .then(val => val + 1); // chained resolves to 21 // Wrong: multiple listeners on same promise basePromise.then(v => console.log('handler A:', v)); basePromise.then(v => console.log('handler B:', v)); // Both log 10 — no transformation shared // Prototype methods: console.log(typeof Promise.prototype.then); // "function" console.log(typeof Promise.prototype.catch); // "function" console.log(typeof Promise.prototype.finally); // "function" // There's no other instance method console.log(Object.getOwnPropertyNames(Promise.prototype)); // ["constructor", "then", "catch", "finally"]
.then() handlers to the same promise object, each gets the original resolved value. They don't see each other's transformations. Chain explicitly or use async/await with assignment..then(), .catch(), .finally(). All return new promises. Never attach multiple handlers to the same promise expecting shared state.Summary
A Promise in JavaScript represents a value that may be available now, later, or never. It decouples the producer (an asynchronous operation) from its consumers (.then(), .catch(), or async/await). A Promise exists in one of three states: pending, fulfilled (resolved value), or rejected (error reason). Once settled, its state and value are immutable — no callback can sneak in to change it later. This immutability is what makes Promises reliable: complex async flows like retries, timeouts, or parallel requests become predictable without the inversion of control found in callbacks. Under the hood, Promises push their .then() callbacks into a microtask queue, ensuring they execute after the current synchronous code but before any macrotask (like setTimeout). Understanding this execution order is critical for debugging race conditions or unexpected state in production code.
// io.thecodeforge — javascript tutorial const p = new Promise((res) => res(1)); console.log('synchronous'); p.then(v => console.log('microtask:', v)); console.log('still sync'); // Output order: synchronous, still sync, microtask: 1
.then() callback runs immediately after resolve(). The microtask queue guarantees deferred execution — any code after the resolve() call runs first.Specifications
The official Promise specification is ECMAScript 2015 (ES6) in section 25.6, refined through ES2020 and ES2024 for the static methods. Key mandates: Promises must use the JavaScript job queue (not the task queue) for .then() handlers; they must enforce the Promise Resolution Procedure, which recursively unwraps any thenable (object or function with a .then() method) to a single Promise; and they must guarantee that an executor runs synchronously before any handler can be attached. The spec explicitly forbids calling resolve or reject more than once — the first call wins and subsequent calls are silently ignored. For browsers, the incumbent settings object tracking (spec section 8.1.6.3) ensures that async operations retain the correct Realm or global scope, preventing cross-origin leaks. These specs are the reason why Promise.all() returns a rejected Promise fast on any rejection, while Promise.allSettled() waits for all to complete even after one failure.
// io.thecodeforge — javascript tutorial const p = new Promise((res) => { res(1); res(2); // silently ignored per spec }); p.then(v => console.log(v)); // logs 1, not 2
Promise.resolve(Promise.resolve(3)) flattens to 3, but a non-Promise thenable like { then: cb => cb(4) } also unwraps.The Silent Rejection That Took Down a Payment Pipeline
.catch() at the end of the main checkout chain would catch all rejections, including those inside nested .then() callbacks that returned promises..then() handler. The outer chain saw undefined and proceeded to 'success' while the inner rejection went unhandled.return before every inner Promise creation. Also installed a global process.on('unhandledRejection', handler) that logs the stack trace to the monitoring system and triggers an alert.- Every Promise returned from a
.then()handler must be explicitlyreturned — otherwise the chain loses it. - Global unhandled rejection handlers are a safety net, not a fix. They catch what you missed, but you must still audit every chain.
- In Node 15+ an unhandled rejection crashes the process — treat it like a thrown exception.
undefined.then() handler — did you forget return on an async call? Add console.log inside each handler to see the argument received.UnhandledPromiseRejectionWarningprocess.on('unhandledRejection', (reason, promise) => { console.error('Unhandled:', reason); }) to see stack traces. Then trace the chain back to where the rejection originates.await inside a loop over an array of promises. Use Promise.all([...]) or Promise.allSettled([...]) to kick them off simultaneously..catch() runs but chain still continues.catch() returns a value, the chain recovers. If you want to stop on error, rethrow inside .catch(): .catch(err => { throw err; }).resolve or reject is never called — missing condition, early return, or unhandled exception inside the executor. Add a timeout wrapper (see quick_debug_cheat_sheet).node --unhandled-rejections=strict app.jsnode -e "process.on('unhandledRejection', (r) => console.error(r.stack))".catch() that logs and throws.Promise.race([yourPromise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))])console.log('State:', await Promise.race([...]).then(()=>'fulfilled', ()=>'rejected'))Promise.race with a rejection after N ms.await previousStep.then(x => { console.log('value:', x); return x; })Look for missing `return` keyword before async calls inside `.then()`return before every internal Promise-returning function call inside .then().| Feature / Aspect | Promise.all() | Promise.allSettled() |
|---|---|---|
| Rejects if one input rejects? | Yes — immediately, with that error | Never — always resolves |
| Result shape | Array of resolved values | Array of {status, value/reason} objects |
| Partial results on failure | No — you get nothing | Yes — successful ones still appear |
| Best for | All-or-nothing operations (e.g. checkout flow) | Resilient dashboards, optional data sources |
| Available since | ES2015 (ES6) | ES2020 |
| Timing | Settles when slowest resolves (or fastest rejects) | Settles when slowest settles |
| Error granularity | Only the first rejection reason | Every rejection reason, individually |
| File | Command / Code | Purpose |
|---|---|---|
| promise-states.js | function fetchUserProfile(userId) { | The Three States of a Promise |
| promise-lifecycle.js | function createPromiseLifecycle() { | Promise State Lifecycle |
| callback-hell-vs-promise.js | const fs = require('fs'); | Callback Hell |
| promise-chaining.js | function authenticateUser(email, password) { | Promise Chaining |
| promisification.js | const fs = require('fs'); | Promisification |
| promise-parallel.js | function loadUserProfile(userId) { | Promise.all vs Promise.allSettled |
| promise-error-handling.js | let isLoadingData = false; | Real-World Error Handling |
| promise-finally.js | let dbConnection = null; | Promise.finally() |
| promise-race-any.js | function fetchWithTimeout(url, timeoutMs = 5000) { | Promise.race and Promise.any |
| promise-static-decision.js | async function demonstrateAll() { | Promise Static Methods Decision Matrix |
| promise-concurrency-comparison.js | const slowSuccess = new Promise(resolve => setTimeout(() => resolve('A'), 500)); | Promise Concurrency Comparison Table |
| async-patterns-comparison.js | function getUserCB(id, cb) { | Callbacks vs Promises vs Async/Await |
| promise-vs-async-await.js | async function loadUserDashboard(userId) { | When to Use Promises vs Async/Await in Production Code |
| promise-exercises.js | function fetchUser(userId) { | Practice Exercises to Master Promises |
| ThenablePaymentFix.javascript | const cacheResult = { | Thenables |
| PromiseConstructorAntiPattern.javascript | function fetchUserWrong(userId) { | Promise Constructor |
| IncumbentSettingsBug.javascript | const iframe = document.getElementById('payment-iframe'); | Incumbent Settings Object |
| promise_instance_debug.js | const fetchData = (id) => new Promise((resolve) => { | Promise Instance Properties |
| promise_proto_chain.js | const basePromise = Promise.resolve(10); | Promise.prototype.then |
| PromiseStateMachine.js | const p = new Promise((res) => res(1)); | Summary |
| SingleResolveSpec.js | const p = new Promise((res) => { | Specifications |
Key takeaways
return inside a .then() is the #1 silent bugPromise.all() is all-or-nothingPromise.allSettled() always completes and tells you exactly which succeeded and which failed, making it the right choice for non-critical parallel data sources..finally() for unconditional cleanup regardless of outcome.Common mistakes to avoid
3 patternsForgetting to return a Promise inside .then()
undefined instead of waiting for the async result, causing silent data loss or race conditions.return keyword before any async call inside a .then() handler: .then((token) => { return fetchUserData(token); }) not .then((token) => { fetchUserData(token); }).Leaving Promise rejections unhandled
UnhandledPromiseRejectionWarning; in the browser it shows as an uncaught error in the console and can crash service workers..catch(), or use a global handler: process.on('unhandledRejection', (reason) => console.error(reason)) as a safety net (but not as a replacement for proper per-chain error handling).Wrapping already-Promise-returning functions in `new Promise()` (explicit Promise constructor antipattern)
return fetch(url).then(res => res.json()) not return new Promise((resolve) => { fetch(url).then(data => resolve(data)); }) — the latter swallows any rejection from fetch silently.Interview Questions on This Topic
What is the difference between Promise.all() and Promise.allSettled(), and when would you choose one over the other in a production application?
Promise.all() takes an array of promises and rejects immediately if any one rejects — you lose all results. It's best for transactions where all parts must succeed (e.g., payment + inventory deduction). Promise.allSettled() waits for all to settle and returns an array of status objects; it never rejects. Use it for dashboards or non-critical parallel fetches where some failure is acceptable.If a .catch() handler in the middle of a Promise chain returns a value instead of re-throwing the error, what happens to the rest of the chain — and why?
Explain the 'Promise constructor antipattern.' What is wrong with wrapping a fetch() call inside `new Promise()`, and what should you do instead?
return fetch(url).then(res => res.json()). If you need to transform the result, use .then().How do you implement a timeout for a Promise in JavaScript using only built-in methods?
Promise.race() between the original promise and a promise that rejects after the timeout: Promise.race([operation, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))]). The race settles on the first settlement — if the timeout fires first, the whole call rejects.Frequently Asked Questions
A callback is a function you pass into another function to be called later — it has no built-in error propagation and nesting multiple callbacks creates deeply indented, hard-to-read code (callback hell). A Promise is an object representing a future value with a standardised API: .then() for success, .catch() for failure, and .finally() for cleanup. Promises can be chained flatly, errors propagate automatically through the chain, and multiple consumers can subscribe to the same Promise independently.
In parallel — all the Promises you pass to Promise.all() are started at the same time (or more precisely, they're all initiated before any of them settle). The total wait time is roughly equal to the slowest individual Promise, not the sum of all of them. This is what makes it a performance tool: three 1-second requests finish in ~1 second with Promise.all(), not ~3 seconds.
async/await is syntactic sugar built directly on top of Promises — it doesn't replace them, it just gives you a way to write Promise-based code that reads like synchronous code. An async function always returns a Promise, and await pauses execution inside that function until the awaited Promise settles. Every async/await pattern can be rewritten as Promise chains, and understanding Promises deeply is essential for debugging async/await, especially when things like Promise.all() or error propagation behave unexpectedly.
Use Promise.allSettled when you want to handle partial failures gracefully — for example, loading multiple independent data sources where one failing shouldn't block the others. Use Promise.all when you need all results to be valid before proceeding, such as a multi-step transaction where every step must succeed.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's Advanced JS. Mark it forged?
15 min read · try the examples if you haven't