JavaScriptintermediate

Promises & Async/Await Deep Dive

Master JavaScript asynchronous programming with Promises and async/await. From fundamentals through advanced patterns including error handling, concurrency, retries, and real-world API patterns.

17 min readยทPublished Mar 2, 2026
promisesasync-awaitasynchronousjavascript

Why Asynchronous JavaScript?

JavaScript is single-threaded. It runs one thing at a time on one call stack. But the real world is full of operations that take time โ€” network requests, file reads, timers, user input. If JavaScript blocked on every slow operation, your UI would freeze.

Asynchronous programming solves this. Instead of waiting, JavaScript registers a callback and moves on. When the operation completes, the callback runs.

Synchronous (blocking):                Asynchronous (non-blocking):

Start                                  Start
  โ”‚                                      โ”‚
  โ”œโ”€โ”€ Fetch data (3s wait)               โ”œโ”€โ”€ Fetch data โ†’ register callback
  โ”‚   ...waiting...                      โ”œโ”€โ”€ Do other work
  โ”‚   ...waiting...                      โ”œโ”€โ”€ Do more work
  โ”‚   ...waiting...                      โ”‚
  โ”œโ”€โ”€ Process data                       โ”œโ”€โ”€ [callback fires] Process data
  โ”œโ”€โ”€ Render UI                          โ”œโ”€โ”€ Render UI
  Done                                   Done

Total: 3s + processing                  Total: processing only

The Callback Problem

Before Promises, we used callbacks. They work but lead to deeply nested, hard-to-read code:

getUser(userId, function (err, user) {
  if (err) return handleError(err);
  getOrders(user.id, function (err, orders) {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, function (err, details) {
      if (err) return handleError(err);
      processPayment(details.total, function (err, receipt) {
        if (err) return handleError(err);
        sendConfirmation(receipt, function (err) {
          if (err) return handleError(err);
          console.log('Done!');
        });
      });
    });
  });
});

This is "callback hell" or the "pyramid of doom." Each level indents further. Error handling is repetitive. Flow is hard to follow.

Promise Anatomy

A Promise is an object representing the eventual result of an async operation. It has three states:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              Promise States                  โ”‚
โ”‚                                              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                โ”‚
โ”‚  โ”‚ PENDING  โ”‚ โ”€โ”€โ”€ initial state              โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜                                โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ”œโ”€โ”€โ”€โ”€ resolve(value) โ”€โ”€โ”€โ”€โ–บ  FULFILLED  โ”‚
โ”‚       โ”‚                           (success)  โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ””โ”€โ”€โ”€โ”€ reject(reason)  โ”€โ”€โ”€โ”€โ–บ REJECTED   โ”‚
โ”‚                                   (failure)  โ”‚
โ”‚                                              โ”‚
โ”‚  Once settled (fulfilled or rejected),       โ”‚
โ”‚  a Promise NEVER changes state again.        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Creating a Promise

The Promise constructor takes an executor function with two arguments โ€” resolve and reject:

const promise = new Promise((resolve, reject) => {
  // async operation here
  const success = true;

  if (success) {
    resolve('Operation completed');  // โ†’ FULFILLED
  } else {
    reject(new Error('Operation failed'));  // โ†’ REJECTED
  }
});

The executor runs immediately (synchronously). The resolution/rejection is what happens asynchronously.

console.log('1 - before Promise');

const p = new Promise((resolve) => {
  console.log('2 - inside executor (synchronous!)');
  resolve('done');
});

p.then((val) => console.log('4 - then handler:', val));

console.log('3 - after Promise');

// Output:
// 1 - before Promise
// 2 - inside executor (synchronous!)
// 3 - after Promise
// 4 - then handler: done

Notice: the executor runs immediately (log 2 before 3), but .then() callbacks are always asynchronous (log 4 after 3), even if the Promise is already resolved.

Consuming Promises

promise
  .then((value) => {
    // handle success
    console.log(value);
  })
  .catch((error) => {
    // handle failure
    console.error(error);
  })
  .finally(() => {
    // runs regardless of outcome
    console.log('cleanup');
  });

.then() returns a new Promise, enabling chaining. .catch() is shorthand for .then(undefined, onRejected). .finally() runs after settlement regardless of outcome and passes the value through.

Promise Chaining

Each .then() returns a new Promise, so you can chain them. Whatever you return from a .then() handler becomes the resolved value of the next Promise in the chain.

fetch('/api/users/1')
  .then((response) => response.json())       // returns parsed JSON
  .then((user) => fetch(`/api/orders/${user.id}`))  // returns fetch Promise
  .then((response) => response.json())
  .then((orders) => {
    console.log('Orders:', orders);
    return orders.length;
  })
  .then((count) => console.log('Count:', count))
  .catch((err) => console.error('Failed:', err));
Chain flow:

fetch() โ”€โ”€โ–บ .then(parse) โ”€โ”€โ–บ .then(fetch orders) โ”€โ”€โ–บ .then(parse) โ”€โ”€โ–บ .then(log)
   โ”‚             โ”‚                    โ”‚                     โ”‚              โ”‚
   โ”‚             โ–ผ                    โ–ผ                     โ–ผ              โ–ผ
   โ”‚         Response obj       fetch Promise          Orders array      count
   โ”‚
   โ””โ”€โ”€โ”€โ”€ if ANY step fails โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ .catch(err)

Return Values in .then()

What you return from .then() determines the next Promise:

// Return a value โ†’ next .then() receives it
Promise.resolve(1)
  .then((x) => x + 1)         // returns 2
  .then((x) => x * 3)         // returns 6
  .then((x) => console.log(x)); // 6

// Return a Promise โ†’ chain waits for it
Promise.resolve(1)
  .then((x) => {
    return new Promise((resolve) => {
      setTimeout(() => resolve(x + 10), 1000);
    });
  })
  .then((x) => console.log(x)); // 11 (after 1 second)

// Return nothing โ†’ next .then() receives undefined
Promise.resolve(1)
  .then((x) => { x + 1; }) // no return statement
  .then((x) => console.log(x)); // undefined

Async/Await

async/await is syntactic sugar over Promises. It makes asynchronous code read like synchronous code.

// Promise chain version
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then((res) => res.json())
    .then((user) => {
      return fetch(`/api/orders/${user.id}`)
        .then((res) => res.json());
    });
}

// async/await version
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  const user = await res.json();

  const ordersRes = await fetch(`/api/orders/${user.id}`);
  const orders = await ordersRes.json();

  return orders;
}

How async/await Works

  • async makes a function always return a Promise
  • await pauses execution until the Promise settles
  • await can only be used inside an async function (or at the top level of ES modules)
async function demo() {
  return 42;
}
// Same as:
function demo() {
  return Promise.resolve(42);
}

demo().then((val) => console.log(val)); // 42

Even if you return a non-Promise value, async wraps it in Promise.resolve().

await Pauses, Not Blocks

A critical distinction: await pauses the async function, not the entire thread. Other code keeps running.

async function slowTask() {
  console.log('A - start');
  await new Promise((r) => setTimeout(r, 1000));
  console.log('C - after await');
}

console.log('1');
slowTask();
console.log('2');

// Output:
// 1
// A - start
// 2             โ† runs immediately, not blocked
// C - after await  โ† runs after 1 second

Error Handling

With Promises

Errors propagate down the chain until caught:

fetch('/api/users/1')
  .then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .then((user) => processUser(user))
  .catch((err) => {
    // catches errors from ANY .then() above
    console.error('Pipeline failed:', err.message);
  });

You can catch at different levels:

fetch('/api/data')
  .then((res) => res.json())
  .then((data) => riskyTransform(data))
  .catch((err) => {
    console.warn('Transform failed, using fallback');
    return fallbackData; // recovered โ€” chain continues
  })
  .then((data) => render(data))
  .catch((err) => {
    console.error('Render failed:', err);
  });

With async/await

Use try/catch โ€” same pattern as synchronous error handling:

async function fetchUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);

    if (!res.ok) {
      throw new Error(`HTTP error: ${res.status}`);
    }

    const user = await res.json();
    return user;
  } catch (err) {
    console.error('Failed to fetch user:', err.message);
    throw err; // re-throw if you want callers to handle it too
  }
}

Error Handling Patterns Comparison

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Pattern                 โ”‚ Use When                        โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ .catch() at end         โ”‚ Single error handler for chain  โ”‚
โ”‚ .catch() mid-chain      โ”‚ Recovery + continue chain       โ”‚
โ”‚ try/catch in async      โ”‚ Standard async error handling   โ”‚
โ”‚ try/catch per await     โ”‚ Different handling per step     โ”‚
โ”‚ .catch() on await       โ”‚ Inline fallback for one call    โ”‚
โ”‚ Global unhandledrejection โ”‚ Safety net for uncaught        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Inline .catch() Pattern

Sometimes you want to handle one specific await failure without a full try/catch:

async function loadDashboard() {
  // If user fetch fails, use guest. Don't abort everything.
  const user = await fetchUser().catch(() => ({ name: 'Guest', role: 'viewer' }));

  // If preferences fail, use defaults
  const prefs = await fetchPreferences(user.id).catch(() => defaultPrefs);

  // This one must succeed
  const data = await fetchDashboardData(user.id);

  return { user, prefs, data };
}

Global Safety Net

Always register an unhandled rejection handler:

// Browser
window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled rejection:', event.reason);
  event.preventDefault(); // prevent default console error
});

// Node.js
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at:', promise, 'reason:', reason);
});

Promise Combinators

JavaScript provides four static methods for working with multiple Promises concurrently.

Promise.all โ€” All Must Succeed

Waits for all Promises to resolve. Rejects immediately if any one rejects.

const [users, products, orders] = await Promise.all([
  fetch('/api/users').then((r) => r.json()),
  fetch('/api/products').then((r) => r.json()),
  fetch('/api/orders').then((r) => r.json()),
]);

// All three requests run in parallel
// Results come back in the same order as the input array
Promise.all timing:

users:    |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
products: |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
orders:   |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|

Result:   |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| โ† resolves when LAST one finishes
                                  (or rejects when FIRST one fails)

Important: if one fails, you lose ALL results, even from Promises that succeeded.

Promise.allSettled โ€” Get All Results

Waits for all Promises to settle (resolve or reject). Never short-circuits.

const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/broken-endpoint'),
  fetch('/api/products'),
]);

// results = [
//   { status: 'fulfilled', value: Response },
//   { status: 'rejected',  reason: Error },
//   { status: 'fulfilled', value: Response },
// ]

const succeeded = results.filter((r) => r.status === 'fulfilled');
const failed = results.filter((r) => r.status === 'rejected');

console.log(`${succeeded.length} succeeded, ${failed.length} failed`);

Promise.race โ€” First to Settle

Returns the result of the first Promise to settle (resolve or reject).

// Timeout pattern
async function fetchWithTimeout(url, timeoutMs = 5000) {
  const result = await Promise.race([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
    ),
  ]);

  return result;
}

try {
  const response = await fetchWithTimeout('/api/slow-endpoint', 3000);
  const data = await response.json();
} catch (err) {
  console.error(err.message); // 'Request timed out' if > 3s
}

Promise.any โ€” First to Succeed

Returns the result of the first Promise to resolve. Ignores rejections unless ALL reject.

// Try multiple CDN sources, use whichever responds first
async function loadScript(filename) {
  const cdns = [
    `https://cdn1.example.com/${filename}`,
    `https://cdn2.example.com/${filename}`,
    `https://cdn3.example.com/${filename}`,
  ];

  try {
    const response = await Promise.any(
      cdns.map((url) => fetch(url))
    );
    return response;
  } catch (err) {
    // AggregateError โ€” all CDNs failed
    console.error('All CDNs failed:', err.errors);
    throw err;
  }
}

Combinator Comparison

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Method               โ”‚ Resolves     โ”‚ Rejects      โ”‚ Use Case           โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Promise.all          โ”‚ All succeed  โ”‚ Any fails    โ”‚ Parallel tasks,    โ”‚
โ”‚                      โ”‚              โ”‚              โ”‚ all required       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Promise.allSettled   โ”‚ All settle   โ”‚ Never*       โ”‚ Batch operations,  โ”‚
โ”‚                      โ”‚              โ”‚              โ”‚ partial failure OK โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Promise.race         โ”‚ First settlesโ”‚ First settlesโ”‚ Timeouts,          โ”‚
โ”‚                      โ”‚              โ”‚              โ”‚ fastest response   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Promise.any          โ”‚ First        โ”‚ All fail     โ”‚ Redundant sources, โ”‚
โ”‚                      โ”‚ succeeds     โ”‚ (Aggregate)  โ”‚ fallback chains    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
* Promise.allSettled always resolves with an array of result objects

Common Pitfalls

1. Swallowing Errors

The most common mistake โ€” a Promise rejection with no .catch():

// BAD โ€” error disappears silently
async function loadData() {
  const data = await fetch('/api/data');
  return data.json();
}
loadData(); // if this fails, no one catches the rejection

// GOOD โ€” handle errors
loadData().catch((err) => console.error('Failed:', err));

// or
try {
  await loadData();
} catch (err) {
  console.error('Failed:', err);
}

2. Sequential When Parallel Is Fine

// BAD โ€” sequential, takes ~3 seconds total
async function loadAll() {
  const users = await fetch('/api/users');       // 1s
  const products = await fetch('/api/products'); // 1s
  const orders = await fetch('/api/orders');     // 1s
  return { users, products, orders };
}

// GOOD โ€” parallel, takes ~1 second total
async function loadAll() {
  const [users, products, orders] = await Promise.all([
    fetch('/api/users'),
    fetch('/api/products'),
    fetch('/api/orders'),
  ]);
  return { users, products, orders };
}
Sequential:
users:    |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
products:          |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
orders:                     |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
Total:    |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| ~3s

Parallel:
users:    |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
products: |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
orders:   |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ|
Total:    |โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| ~1s

3. Using await in forEach

forEach doesn't wait for async callbacks. Use for...of or Promise.all with .map().

const urls = ['/api/a', '/api/b', '/api/c'];

// BAD โ€” fires all requests, doesn't wait for any
urls.forEach(async (url) => {
  const data = await fetch(url);
  console.log(await data.json());
});
console.log('Done'); // runs BEFORE any fetch completes

// GOOD โ€” sequential processing
for (const url of urls) {
  const data = await fetch(url);
  console.log(await data.json());
}
console.log('Done'); // runs AFTER all fetches complete

// GOOD โ€” parallel processing
const results = await Promise.all(
  urls.map(async (url) => {
    const data = await fetch(url);
    return data.json();
  })
);
console.log('Done', results);

4. Creating Unnecessary Promises

// BAD โ€” wrapping an existing Promise in another Promise
function getUser(id) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${id}`)
      .then((res) => res.json())
      .then((data) => resolve(data))
      .catch((err) => reject(err));
  });
}

// GOOD โ€” just return the Promise chain
function getUser(id) {
  return fetch(`/api/users/${id}`).then((res) => res.json());
}

// ALSO GOOD โ€” async functions already return Promises
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

5. Not Checking Response.ok

fetch only rejects on network failure, not HTTP error codes:

// BAD โ€” 404/500 responses will NOT throw
const res = await fetch('/api/missing');
const data = await res.json(); // might parse an error body

// GOOD โ€” check response status
const res = await fetch('/api/missing');
if (!res.ok) {
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();

6. Race Condition with State Updates

// BAD โ€” if user types fast, responses arrive out of order
searchInput.addEventListener('input', async (e) => {
  const results = await search(e.target.value);
  renderResults(results); // might render stale results
});

// GOOD โ€” track request order
let lastRequestId = 0;

searchInput.addEventListener('input', async (e) => {
  const requestId = ++lastRequestId;
  const results = await search(e.target.value);

  // only render if this is still the latest request
  if (requestId === lastRequestId) {
    renderResults(results);
  }
});

Real-World Patterns

Retry with Exponential Backoff

async function fetchWithRetry(url, options = {}) {
  const { maxRetries = 3, baseDelay = 1000, maxDelay = 10000 } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);

      if (!response.ok && response.status >= 500) {
        throw new Error(`Server error: ${response.status}`);
      }

      return response;
    } catch (err) {
      if (attempt === maxRetries) throw err;

      const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
      const jitter = delay * (0.5 + Math.random() * 0.5);

      console.warn(
        `Attempt ${attempt + 1} failed. Retrying in ${Math.round(jitter)}ms...`
      );

      await new Promise((r) => setTimeout(r, jitter));
    }
  }
}

// Usage
const response = await fetchWithRetry('/api/unstable-endpoint', {
  maxRetries: 5,
  baseDelay: 500,
});

Concurrent Task Queue

Limit concurrent async operations to prevent overwhelming a server:

async function asyncPool(concurrency, items, iteratorFn) {
  const results = [];
  const executing = new Set();

  for (const [index, item] of items.entries()) {
    const promise = Promise.resolve().then(() => iteratorFn(item, index));
    results.push(promise);
    executing.add(promise);

    const cleanup = () => executing.delete(promise);
    promise.then(cleanup, cleanup);

    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
  }

  return Promise.all(results);
}

// Upload 100 files, max 5 at a time
const files = getFilesToUpload(); // 100 files

const results = await asyncPool(5, files, async (file) => {
  const formData = new FormData();
  formData.append('file', file);
  const res = await fetch('/api/upload', { method: 'POST', body: formData });
  return res.json();
});

Cancelable Async Operations

Using AbortController:

function createCancelableRequest(url) {
  const controller = new AbortController();

  const promise = fetch(url, { signal: controller.signal })
    .then((res) => res.json());

  return {
    promise,
    cancel: () => controller.abort(),
  };
}

// Usage
const { promise, cancel } = createCancelableRequest('/api/slow-data');

// Cancel after 3 seconds if not done
const timeout = setTimeout(cancel, 3000);

try {
  const data = await promise;
  clearTimeout(timeout);
  console.log(data);
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('Request was cancelled');
  } else {
    throw err;
  }
}

Sequential Pipeline

Process items one at a time, feeding each result into the next:

async function pipeline(initialValue, ...fns) {
  let result = initialValue;

  for (const fn of fns) {
    result = await fn(result);
  }

  return result;
}

const processedUser = await pipeline(
  userId,
  async (id) => {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  },
  async (user) => {
    const res = await fetch(`/api/enrich?email=${user.email}`);
    const extra = await res.json();
    return { ...user, ...extra };
  },
  async (user) => {
    await fetch('/api/audit', {
      method: 'POST',
      body: JSON.stringify({ action: 'view', userId: user.id }),
    });
    return user;
  }
);

Polling Pattern

async function poll(fn, { interval = 2000, timeout = 30000, validate }) {
  const start = Date.now();

  while (Date.now() - start < timeout) {
    const result = await fn();

    if (validate(result)) {
      return result;
    }

    await new Promise((r) => setTimeout(r, interval));
  }

  throw new Error('Polling timed out');
}

// Wait for a job to complete
const result = await poll(
  () => fetch('/api/jobs/123').then((r) => r.json()),
  {
    interval: 3000,
    timeout: 60000,
    validate: (job) => job.status === 'completed',
  }
);

Promise-based Event Listener

Wait for a one-time event:

function waitForEvent(element, eventName, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error(`Timeout waiting for ${eventName}`));
    }, timeout);

    element.addEventListener(
      eventName,
      (event) => {
        clearTimeout(timer);
        resolve(event);
      },
      { once: true }
    );
  });
}

// Usage
const clickEvent = await waitForEvent(button, 'click', 10000);
console.log('Button was clicked at', clickEvent.clientX, clickEvent.clientY);

Promisifying Callback-Based APIs

Convert callback-style functions to Promise-based:

function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn(...args, (err, result) => {
        if (err) reject(err);
        else resolve(result);
      });
    });
  };
}

// Convert Node.js fs.readFile
import fs from 'fs';
const readFile = promisify(fs.readFile);

const content = await readFile('/path/to/file', 'utf-8');

// Node.js also has util.promisify built in:
import { promisify as nodePromisify } from 'util';
const readFileAsync = nodePromisify(fs.readFile);

async Iteration

for await...of handles async iterables โ€” streams of Promises:

async function* fetchPages(baseUrl) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const res = await fetch(`${baseUrl}?page=${page}`);
    const data = await res.json();

    yield data.items;

    hasMore = data.hasNextPage;
    page++;
  }
}

// Consume async generator
for await (const items of fetchPages('/api/products')) {
  for (const item of items) {
    processItem(item);
  }
}

Testing Async Code

With Jest/Vitest

// Return the Promise
test('fetches user data', () => {
  return fetchUser(1).then((user) => {
    expect(user.name).toBe('Alice');
  });
});

// async/await (preferred)
test('fetches user data', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

// Testing rejections
test('throws on invalid id', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID');
});

// Mocking async functions
jest.spyOn(global, 'fetch').mockResolvedValue({
  ok: true,
  json: async () => ({ id: 1, name: 'Alice' }),
});

Faking Timers

test('retries on failure', async () => {
  jest.useFakeTimers();

  const mockFetch = jest
    .fn()
    .mockRejectedValueOnce(new Error('fail'))
    .mockRejectedValueOnce(new Error('fail'))
    .mockResolvedValueOnce({ ok: true });

  const promise = fetchWithRetry('/api/test');

  // Fast-forward through retry delays
  await jest.advanceTimersByTimeAsync(10000);

  const result = await promise;
  expect(result.ok).toBe(true);
  expect(mockFetch).toHaveBeenCalledTimes(3);

  jest.useRealTimers();
});

Microtask Priority

Promises use the microtask queue, which has higher priority than the macrotask queue (setTimeout, setInterval). This matters for execution order:

console.log('1 - sync');

setTimeout(() => console.log('2 - macrotask'), 0);

Promise.resolve().then(() => console.log('3 - microtask'));

console.log('4 - sync');

// Output:
// 1 - sync
// 4 - sync
// 3 - microtask    โ† runs BEFORE setTimeout
// 2 - macrotask

For a deeper dive into task scheduling, see the Event Loop article.

Key Takeaways

  1. Promises represent future values โ€” pending, fulfilled, or rejected
  2. Always handle rejections โ€” unhandled rejections crash Node.js and clutter browser consoles
  3. async/await is syntactic sugar โ€” it compiles to Promise chains under the hood
  4. Use Promise.all for parallel work โ€” don't await sequentially when operations are independent
  5. Use Promise.allSettled when partial failure is OK โ€” get all results regardless of individual failures
  6. Check response.ok โ€” fetch doesn't reject on HTTP errors
  7. Avoid await in forEach โ€” use for...of or Promise.all with map
  8. Don't wrap Promises in Promises โ€” return the chain directly
  9. Handle race conditions โ€” track request IDs or use AbortController
  10. Promises resolve via microtask queue โ€” they run before setTimeout/setInterval callbacks

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles