Mid-level 13 min · March 05, 2026

JavaScript Promises — The Missing Return That Broke Payment

No error logs, no alerts—yet payments went unpending after confirmation emails.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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; always return the inner Promise
  • Promise.all() fails fast — one rejection loses all results
  • Promise.allSettled() never rejects — gives partial results safely
  • Unhandled rejections crash Node 15+ — always append .catch()
Plain-English First

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.

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.

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.

promise-states.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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);
  });
Output
Promise is now: PENDING
Promise is now: PENDING
// ...after ~1 second...
Promise is now: FULFILLED
User loaded: Alice Johnson | Role: admin
Second handler also received: 42
Promise is now: REJECTED
Error caught: Invalid userId: -1. Must be a positive integer.
Why This Matters:
Because a settled Promise caches its result, you can safely pass a Promise reference into multiple modules. Each module calls .then() independently and always gets the same value — no shared mutable state, no timing bugs.
Production Insight
A common production bug: calling 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.
Check for double-resolve by logging before each resolve call during development.
Rule: design each executor to call resolve OR reject exactly once — never both, never twice.
Key Takeaway
Once settled, a Promise's state is immutable.
You can attach .then() after completion and still get the value.
Treat Promises as fire-and-forget value holders — they never change after settling.

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.

promise-lifecycle.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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);
Output
Created Promise — state: PENDING
// after ~500ms:
resolve() called — state: FULFILLED (or reject)
Attempting double-resolve — ignored by runtime
Handler received: ✅ Success: Operation completed (or error)
Attaching handler after settlement:
Late handler still gets: ✅ Success: Operation completed (same value)
Memory Leak Risk:
A Promise that never settles (stays pending) will keep its executor scope alive, preventing garbage collection of any variables referenced inside the executor. Always ensure resolve or reject is called, or use a cleanup mechanism like clearTimeout.
Production Insight
In production, a Promise that hangs indefinitely is often a silent resource leak. For example, a database query Promise that never settles keeps the connection pool slot occupied. Add a timeout wrapper around every external call using Promise.race to guarantee settlement within a reasonable window.
Also, log the creation stack of every Promise in development to trace which executor is missing its resolve/reject call.
Key Takeaway
Promises have exactly two one‑way transitions: pending → fulfilled or pending → rejected. Once settled, state is permanent and cached. Ensure every executor eventually calls resolve or reject.

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.

callback-hell-vs-promise.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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));
Output
// Successful execution:
Config loaded: myapp
// Failure:
Failed: ENOENT: no such file or directory, open '/app/config.json'
Historical Contrast:
Callback hell wasn't just an aesthetic problem. Each nested callback created a new closure, making memory management harder. Error propagation required explicit if(err) checks at every level — one missed check and the error disappeared silently. Promises standardised both error handling and control flow.
Production Insight
In production, callback-based APIs still exist (e.g., some database drivers, file system operations). When you must use them, promisify at the module boundary. Never nest callbacks manually if you can avoid it — it's a leading cause of 'lost' errors in legacy systems.
Rule: if you see three or more levels of callback nesting, stop and refactor to Promises or async/await.
Key Takeaway
Callback hell makes code unreadable and error propagation fragile. Promises flatten the pyramid and bring predictable error handling. Always promisify callback-based APIs to avoid the nesting trap.

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.

promise-chaining.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 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);
  });
Output
// After ~500ms:
Step 1 complete — token received: jwt_abc123xyz
// After ~700ms more:
Step 2 complete — raw stats received for user: 42
Step 3 complete — ready to render:
Headline: 128 total orders
Alert: ⚠ 3 orders need attention
Revenue: $14,750.50
Caught in chain: Authentication failed: invalid credentials
Watch Out:
Forgetting to RETURN the inner Promise inside a .then() is the single most common Promise bug. Without the return keyword, the chain doesn't wait — it races ahead with undefined. Always return async calls from inside .then() handlers.
Production Insight
In a production microservice, one missing 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'.
Always add a lint rule like @typescript-eslint/no-floating-promises to catch unreturned promises.
Rule: every async call inside .then() must be prefixed with return — treat this as a non-negotiable pattern.
Key Takeaway
Return every inner Promise from .then().
A single .catch() at the end catches all upstream errors.
Synchronous throws inside .then() become rejections automatically.

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.

promisification.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 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));
Output
// For a successful read:
Config loaded successfully.
// For a missing file:
Failed to load config: ENOENT: no such file or directory, open '/etc/app/config.json'
Promisify Pitfall:
Some callbacks are called multiple times (e.g., event emitters). Promisification only works for one-shot callbacks. If the callback can be invoked more than once, you need an Observable or EventEmitter, not a Promise. Use util.promisify only on functions that call their callback exactly once.
Production Insight
In production, avoid promisifying low-level async operations inside hot loops. Each 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.
Also, some modern libraries (like 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.
Rule: promisify at the module level, not inside functions.
Key Takeaway
Promisification wraps callback-based functions to return Promises, enabling .then() and async/await. Use util.promisify for Node.js style functions. Always verify the callback is called exactly once before promisifying.

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.

promise-parallel.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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)`);
  });
Output
--- Testing Promise.all ---
Promise.all rejected after 401ms
Error: Notifications service temporarily unavailable
Profile and orders data is lost — even though they succeeded!
--- Testing Promise.allSettled ---
✓ Profile loaded: { name: 'Alice Johnson', plan: 'Pro' }
✓ Orders loaded: [ { id: 'ORD-001', total: 49.99 }, { id: 'ORD-002', total: 120 } ]
✗ Notifications failed: Notifications service temporarily unavailable
allSettled resolved after 601ms (waited for all)
Pro Tip:
The total time for Promise.all and Promise.allSettled is determined by the SLOWEST Promise, not the sum of all of them. Three 600ms requests run in parallel and finish in ~600ms total — not 1800ms. This is the real performance win of parallel async execution.
Production Insight
We once used Promise.all for a dashboard that loaded 10 data widgets. One flaky third‑party API took down the entire page. Users saw a blank screen with no error. We switched to allSettled — now each widget shows its own error state, and the rest of the page renders perfectly.
The performance cost of allSettled is negligible: it waits for every promise instead of fast‑failing. But that's exactly what you want for resilience.
Rule: use allSettled for every non‑critical parallel fetch. Reserve all for transactions where all parts must succeed.
Key Takeaway
Promise.all fails fast but loses everything.
Promise.allSettled never fails — gives partial results.
Pick based on business criticality, not performance.

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.

promise-error-handling.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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);
  });
Output
=== Valid category ===
Loading spinner: ON
Loaded 2 products from API
Rendering 2 product(s) to UI
Loading spinner: OFF — cleanup complete
Final items received by caller: [ 'Wireless Headphones', 'USB-C Hub' ]
=== Invalid category (triggers fallback) ===
Loading spinner: ON
API failed, using fallback data. Reason: Category 'furniture' not found in catalog
Rendering 1 product(s) to UI
Loading spinner: OFF — cleanup complete
Final items received by caller: [ 'No products available' ]
Always attach .catch() to every Promise chain: Category 'invalid-category' not found in catalog
Watch Out:
A .catch() in the MIDDLE of a chain that returns a value silently recovers the chain — subsequent .then() calls WILL execute. If you intend to stop the chain on error, re-throw inside .catch(): catch((err) => { throw err; }). Otherwise your 'error handler' is actually a recovery handler.
Production Insight
An e‑commerce site had a .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.
The fix: always rethrow unless you explicitly want fallback data. And put your catch at the very end, not in the middle.
Rule: a .catch() in the middle is not an error handler — it's a recovery handler.
Key Takeaway
Catch at the end of the chain — not in the middle.
Use .finally() for cleanup — it doesn't modify the value.
If you must catch mid‑chain, rethrow unless fallback is intentional.

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.

promise-finally.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 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);
Output
// First query (likely success):
Connected to database
Query "SELECT * FROM users" executed successfully
Closing database connection...
Cleanup complete
Result: Query "SELECT * FROM users" executed successfully
// Second query (likely failure):
Connected to database
Query error: DROP queries are not allowed
Closing database connection...
Cleanup complete
Final error caught: DROP queries are not allowed
Cleanup Guarantee:
Always use .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().
Production Insight
In a production WebSocket service, we had a bug where database connections were never released when a query failed — the .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.
Rule: every resource acquisition (DB connection, file handle, socket) must have a corresponding .finally() release, not just a .catch().
Key Takeaway
Use .finally() for unconditional cleanup. It runs after both resolution and rejection. Chain it before the final .catch() so errors still propagate. Never put cleanup in both .then() and .catch() — that's what .finally() is for.

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.

promise-race-any.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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);
  }
}
Output
// If the API responds within 3 seconds:
Data: { ... }
// If the API is slow:
Failed: Request timed out after 3000ms
// If cdn1 succeeds first:
Config from fastest server: { ... }
// If both cdn1 and cdn2 fail:
All servers failed: [Error: HTTP 500 from https://cdn1.example.com/config, Error: HTTP 503 from https://cdn2.example.com/config]
When to Use Each:
Promise.race for timeouts or fail-fast scenarios. Promise.any for redundancy (try multiple sources, use first success). Remember: Promise.race settles on the first settlement (could be rejection), Promise.any settles on first fulfillment.
Production Insight
A service that fetched configuration from a primary and backup CDN used Promise.race. When the primary returned a 503 (rejected) before the backup responded, the whole request failed — even though the backup was healthy. Switching to Promise.any fixed it: the backup's success was used when the primary failed fast.
But be careful: if both CDNs are slow, Promise.any waits for all to settle (since it needs to see all rejections before it can reject). That can be slower than a timeout.
Rule: use race for timeouts, any for redundancy. And always add a timeout wrapper around the entire any call.
Key Takeaway
Promise.race settles on first result — any outcome.
Promise.any waits for first success — all reject = AggregateError.
Always pair Promise.any with a timeout to avoid hanging.

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.

promise-static-decision.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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();
Output
all: B fails
allSettled: [ 'fulfilled', 'rejected' ]
race: B fails
any: C
Performance Note:
All four methods are non-blocking — they kick off all promises immediately. The total time is always the slowest promise (or the fastest rejection for all). None of them run promises sequentially unless you await inside a loop manually.
Production Insight
In production, we've seen teams default to Promise.all because it's the most familiar, only to have a single flaky service take down an entire page. Document your choice: if a different combinator would be more resilient, leave a comment explaining why you chose the current one. Also, always add a top-level catch to any combinator call — even allSettled can throw if the input is not iterable.
Rule: use a decision matrix comment in code: // allSettled intentionally — widget failures are non-fatal.
Key Takeaway
Promise.all for all-or-nothing transactions; allSettled for partial resilience; race for timeouts; any for redundancy. The right choice prevents silent failures and improves user experience.

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.

MethodResolves whenRejects whenResult shapeUse case
Promise.all()All promises fulfillAny promise rejects (fast)Array of fulfilled valuesAll-or-nothing transactions
Promise.allSettled()All promises settle (fulfill or reject)Never rejectsArray 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 errorTimeouts, fail-fast
Promise.any()First promise fulfillsAll promises reject (with AggregateError)Single fulfilled valueRedundancy, 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.

promise-concurrency-comparison.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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();
Output
--- Promise.all ---
Rejected with: B fails
--- Promise.allSettled ---
fulfilled
rejected
--- Promise.race ---
Rejected with: B fails
--- Promise.any ---
Resolved: C
Quick Decision:
If you need all results and can't tolerate any failure → all. If you want partial results and resilience → allSettled. If you need the fastest response (timeout or fallback) → race. If you want the first successful response from multiple sources → any.
Production Insight
We once had a microservice that used Promise.race between two redundant databases. The primary failed quickly (rejected), causing the whole request to fail — even though the backup would have succeeded. Switched to Promise.any with a timeout, and the service became resilient. But be aware: any waits for all promises to settle before rejecting, so always pair with a timeout.
Rule: label your Promise combinator choice in comments so future developers know why you didn't use the default all.
Key Takeaway
Master the four combinators: all (fail-fast), allSettled (partial results), race (first settlement), any (first success). The wrong choice can silently lose data or crash services.

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.

AspectCallbacksPromisesAsync/Await
ReadabilityDeeply nested — pyramid of doomFlat chain with .then()Synchronous-looking linear code
Error handlingManual if(err) propagationAutomatic via .catch()try/catch blocks
ComposabilityHard to compose multiple async operationsBuilt-in combinators (all, race, etc.)Built-in combinators still use Promises
Sequential executionNested callbacks.then() chainSequential awaits
Parallel executionRequires boilerplate countersPromise.all / allSettledPromise.all / allSettled (same)
Error propagationMust pass error manually up each levelAutomatically flows to nearest .catch()Automatically flows to nearest catch in async function
Return valueNot applicable (void).then() returns new Promiseasync function returns Promise
DebuggingStack traces often unhelpfulBetter, but still indirectFull stack traces with line numbers
Modern adoptionLegacy onlyWidely used internally by frameworksPreferred 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.

async-patterns-comparison.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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);
Output
Callback: Alice has 2 posts
Promise: 2
Async/await: Alice has 2 posts
Important:
Async/await does NOT replace Promises — it's syntax that makes Promise-based code more readable. You still need Promises for combinators (all, allSettled, race, any), and you must understand how .then() chains work to debug async/await when things go wrong.
Production Insight
In production, we often mix patterns — using async/await for sequential logic and Promise.all for parallel. But never mix callback-style and Promises in the same function. If you're refactoring legacy code, convert callbacks to Promises at the module boundary first, then replace .then() chains with async/await gradually.
Rule: new code should use async/await; internal library code may use Promises directly for flexibility; callback-based APIs should be promisified at the import boundary.
Key Takeaway
Async/await is syntactic sugar over Promises. Use it for readability, but always remember you're working with Promises. All the rules about returning, error propagation, and combinators still apply.

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.

promise-vs-async-await.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 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 };
}
Output
// No direct output — see code comments for patterns
Team Consistency Tip:
Pick one primary style and stick to it. If your team prefers async/await, use it even for simple two-step chains. Mixing styles confuses code reviews and increases cognitive load. Exception: Promise combinators are used with async/await via Promise.all etc.
Production Insight
In a large microservice repo, we had one developer using .then() chains everywhere and another using async/await. Bugs kept appearing because the .then() chains didn't return the inner promise, while the async/await functions missed error handling. We settled on a rule: every async function uses try/catch, and Promise combinators are the only place where .then() appears (and only for transformation).
Rule: inside an async function, use await; never use .then() unless you're chaining a combinator call with a transformation callback.
Key Takeaway
Use async/await for sequential readability, Promise chains for fine-grained transformation, and Promise combinators for parallelism. Be consistent with your team's chosen style.

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.

promise-exercises.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 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; });
}
Output
// For Exercise 1:
Comments: [ 'Comment 1', 'Comment 2' ]
// Other exercises produce no console output by default — run them to see results.
Progression:
Start with Exercise 1 (sequential) and 2 (parallel). Then move to 3 (timeout) and 4 (retry). Finally, Exercise 5 (pipeline) ties everything together. Each exercise builds on concepts from earlier sections.
Production Insight
These exercises mirror real production patterns we've encountered: sequential API orchestration, parallel data fetching for dashboards, timeout protection to prevent hanging, retries for flaky services, and data pipelines with cleanup. Mastering them means you're ready to handle the majority of async patterns in production.
Rule: always add retries and timeouts to any external call in production — networks are unreliable.
Key Takeaway
Practice these five exercises to solidify Promise chains, parallel execution, error handling, retries, and cleanup. These patterns cover 90% of real-world async needs in JavaScript.
● Production incidentPOST-MORTEMseverity: high

The Silent Rejection That Took Down a Payment Pipeline

Symptom
Customers received order confirmation emails, but no payment was captured. Support saw 'Payment Pending' indefinitely. No error logs or alerts fired.
Assumption
The team assumed the .catch() at the end of the main checkout chain would catch all rejections, including those inside nested .then() callbacks that returned promises.
Root cause
The payment validation step returned a new Promise that was not returned from inside the .then() handler. The outer chain saw undefined and proceeded to 'success' while the inner rejection went unhandled.
Fix
Added 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.
Key lesson
  • Every Promise returned from a .then() handler must be explicitly returned — 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.
Production debug guideUse these symptom-action pairs to trace and fix broken async flows.5 entries
Symptom · 01
Chain resolves but value is undefined
Fix
Check every .then() handler — did you forget return on an async call? Add console.log inside each handler to see the argument received.
Symptom · 02
Node process crashes with UnhandledPromiseRejectionWarning
Fix
Register process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled:', reason); }) to see stack traces. Then trace the chain back to where the rejection originates.
Symptom · 03
Parallel requests run sequentially (slow)
Fix
Ensure you're not using await inside a loop over an array of promises. Use Promise.all([...]) or Promise.allSettled([...]) to kick them off simultaneously.
Symptom · 04
.catch() runs but chain still continues
Fix
If .catch() returns a value, the chain recovers. If you want to stop on error, rethrow inside .catch(): .catch(err => { throw err; }).
Symptom · 05
Promise never settles (hangs forever)
Fix
Check if 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).
★ Quick Debug: Promise Chain FailureWhen your async pipeline breaks, run these commands in Node.js (with the --inspect flag if needed) to find the root cause fast.
Unhandled rejection crash
Immediate action
Set NODE_OPTIONS='--unhandled-rejections=strict'
Commands
node --unhandled-rejections=strict app.js
node -e "process.on('unhandledRejection', (r) => console.error(r.stack))"
Fix now
Add a global handler OR ensure every chain ends with .catch() that logs and throws.
Timeout/hanging promise+
Immediate action
Wrap the promise with a timeout race
Commands
Promise.race([yourPromise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))])
console.log('State:', await Promise.race([...]).then(()=>'fulfilled', ()=>'rejected'))
Fix now
Add a timeout to every external call using Promise.race with a rejection after N ms.
Returned value is undefined+
Immediate action
Log the previous step's output
Commands
await previousStep.then(x => { console.log('value:', x); return x; })
Look for missing `return` keyword before async calls inside `.then()`
Fix now
Add return before every internal Promise-returning function call inside .then().
Feature / AspectPromise.all()Promise.allSettled()
Rejects if one input rejects?Yes — immediately, with that errorNever — always resolves
Result shapeArray of resolved valuesArray of {status, value/reason} objects
Partial results on failureNo — you get nothingYes — successful ones still appear
Best forAll-or-nothing operations (e.g. checkout flow)Resilient dashboards, optional data sources
Available sinceES2015 (ES6)ES2020
TimingSettles when slowest resolves (or fastest rejects)Settles when slowest settles
Error granularityOnly the first rejection reasonEvery rejection reason, individually

Key takeaways

1
A Promise is permanently settled once it moves from pending to fulfilled or rejected
this immutability means you can safely pass it around and attach multiple .then() handlers to the same Promise without timing concerns.
2
Forgetting return inside a .then() is the #1 silent bug
without it the chain doesn't wait for the inner async work, and the next .then() receives undefined immediately.
3
Promise.all() is all-or-nothing
one rejection kills the whole result. Promise.allSettled() always completes and tells you exactly which succeeded and which failed, making it the right choice for non-critical parallel data sources.
4
A .catch() that returns a value (rather than re-throwing) recovers the chain back to fulfilled
this is a feature, not a bug, but it must be intentional. Use .finally() for unconditional cleanup regardless of outcome.
5
Promise.race for timeouts, Promise.any for redundancy. Always pair any with a timeout to avoid hanging on slow resources.

Common mistakes to avoid

3 patterns
×

Forgetting to return a Promise inside .then()

Symptom
The next .then() in the chain fires immediately with undefined instead of waiting for the async result, causing silent data loss or race conditions.
Fix
Always include the return keyword before any async call inside a .then() handler: .then((token) => { return fetchUserData(token); }) not .then((token) => { fetchUserData(token); }).
×

Leaving Promise rejections unhandled

Symptom
In Node.js 15+ the process crashes with UnhandledPromiseRejectionWarning; in the browser it shows as an uncaught error in the console and can crash service workers.
Fix
Every Promise chain must end with a .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)

Symptom
Doubled error handling complexity, swallowed rejections, and verbose code. If the inner promise rejects, the outer promise may never settle.
Fix
If a function already returns a Promise (like fetch), just chain directly: 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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between Promise.all() and Promise.allSettled(), a...
Q02SENIOR
If a .catch() handler in the middle of a Promise chain returns a value i...
Q03SENIOR
Explain the 'Promise constructor antipattern.' What is wrong with wrappi...
Q04SENIOR
How do you implement a timeout for a Promise in JavaScript using only bu...
Q01 of 04SENIOR

What is the difference between Promise.all() and Promise.allSettled(), and when would you choose one over the other in a production application?

ANSWER
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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between a Promise and a callback in JavaScript?
02
Does Promise.all() run requests in parallel or in sequence?
03
What's the relationship between Promises and async/await?
04
When should I use Promise.allSettled instead of Promise.all?
🔥

That's Advanced JS. Mark it forged?

13 min read · try the examples if you haven't

Previous
Closures in JavaScript
2 / 27 · Advanced JS
Next
async and await in JavaScript