Promise Error Handling in JavaScript: Common Pitfalls
AI generated
JS
() =>
JavaScript · Promises · Debugging
Promise Error Handling in JavaScript
common pitfalls with try catch and async await

An error inside an async function disappears without a trace, the console shows an unhandled rejection, an empty catch block hides exactly the problem you were looking for. Promise error handling follows its own rules, different from synchronous try catch. This article shows the concrete pitfalls and how to reliably avoid them.

18 min read try catch · async await · Promise.all · allSettled Debugging · error handling

1. Why error handling for promises differs from synchronous code

In synchronous code, a single try catch block reliably catches every thrown error within its scope, because execution runs linearly and blocking. Promise error handling follows a different logic, because a promise only reaches its fulfilled or rejected state at a later, unpredictable point in time, often after the surrounding synchronous code has already fully run. An error thrown inside a promise callback never leaves the promise itself, instead it is stored inside the promise's internal state as a rejection and must be actively picked up.

This exact shift from synchronous throwing to asynchronous storing is the root of almost every pitfall around promise error handling. A try catch block wrapping an asynchronous operation without correctly synchronizing with await simply never sees the later error, because the block has already been exited by the time the error occurs. Anyone who has not internalized this timing shift loses errors in seemingly correct looking code, without any error message ever reaching their own catch block.

The following sections walk through the concrete situations where promise error handling typically fails: forgotten await, unhandled rejections, empty catch blocks, and the different error behavior of Promise.all versus Promise.allSettled.

2. Forgotten await: when try catch does not catch the error

The most common entry point into broken promise error handling is calling a promise without await inside a try catch block. Without await, the async function immediately returns a still pending promise, and the try catch block is exited right away, long before the actual asynchronous operation has even completed. If the operation fails later, the try catch block no longer exists at that point, and the error ends up as an unhandled rejection outside any error handling whatsoever.

This pitfall is especially tricky because the code looks completely correct at a glance: try, an async function call, catch, everything seems to be in its place. Only the missing await keyword decides whether promise error handling actually works or runs into a void. This is exactly why an ESLint rule like require-await combined with no-floating-promises from the TypeScript ESLint plugin is so valuable, since it flags a missing await right in the editor instead of only discovering it as a silent production bug.


async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`User ${id} not found`);
  }
  return response.json();
}

// WRONG: missing "await" — the try/catch block exits before the error occurs
async function loadUserWrong(id) {
  try {
    fetchUser(id); // no await! The promise rejection is never caught here
  } catch (error) {
    console.log('This will never run for async errors from fetchUser');
  }
}

// RIGHT: awaiting the promise lets try/catch see the rejection
async function loadUserRight(id) {
  try {
    const user = await fetchUser(id);
    return user;
  } catch (error) {
    console.error('Failed to load user:', error.message);
    throw error; // re-throw if the caller also needs to react
  }
}

3. Unhandled rejections: silent errors nobody sees

A promise that is rejected with no attached error handling whatsoever produces what is called an unhandled rejection. In the browser this shows up as a console warning, in Node.js an unhandled rejection can even terminate the entire process, depending on version and configuration. The actual pitfall is that an unhandled rejection is not created the instant the error is thrown, but only after the microtask queue has been fully drained and it is established that no catch will be attached afterward.

In practice, unhandled rejections often arise from fire and forget calls, where a promise is deliberately not awaited, for example when sending analytics events that should not block the main flow. If no explicit error handling is added for such calls, a failed analytics call disappears quietly from the user's perspective, but produces recurring noise in the console that drowns out real errors. The robust fix is to attach a dedicated .catch() to every intentionally unawaited promise anyway, even if it merely logs and triggers no further action.


// WRONG: fire-and-forget without any error handling produces an unhandled rejection
function trackEvent(name) {
  fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ name }) });
  // If this fetch fails, the rejection is never handled anywhere
}

// RIGHT: always attach a catch, even for intentionally unawaited promises
function trackEventSafe(name) {
  fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ name }) })
    .catch((error) => console.warn('Analytics call failed, ignoring:', error.message));
}

// Node.js: a global safety net, but it should never replace local handling
process.on('unhandledRejection', (reason) => {
  console.error('Unhandled rejection detected:', reason);
});

4. Errors in .then chains: where catch actually catches

In a chain of several .then() calls, a .catch() attached at the end catches errors from every preceding .then() callback, not just the immediately preceding one. This is because a rejection skips over the chain until it hits the next handler that actually deals with an error case, no matter how many successful .then() steps sit in between. This property makes a single, trailing .catch() a convenient but sometimes too coarse tool for promise error handling, since it does not distinguish which link in the chain actually produced the error.

Anyone who wants to treat different error types from different chain links differently, for example handling a network error differently from a validation error, should place local .catch() handlers between individual chain links that either finally handle the error or deliberately rethrow it. With async/await, the equivalent pattern is a try catch block per logical section instead of one single block around the entire function, which in practice is often clearer than deeply nested .then() chains with multiple .catch() calls.


// A single trailing catch handles errors from ANY step in the chain
fetch('/api/data')
  .then((response) => response.json())
  .then((data) => processData(data))
  .then((result) => saveResult(result))
  .catch((error) => console.error('Failed at some step:', error.message));

// Equivalent with async/await, easier to reason about which step failed
async function loadAndProcess() {
  let data;
  try {
    const response = await fetch('/api/data');
    data = await response.json();
  } catch (error) {
    throw new Error(`Network or parsing failed: ${error.message}`);
  }

  try {
    const result = processData(data);
    return await saveResult(result);
  } catch (error) {
    throw new Error(`Processing or saving failed: ${error.message}`);
  }
}

5. Promise.all fail-fast vs. Promise.allSettled compared

Promise.all() follows a fail fast behavior: as soon as a single one of the passed promises rejects, the combined promise rejects immediately too, regardless of whether the remaining promises would still have completed successfully. That is exactly right when every sub request must necessarily succeed, for example when loading several mandatory resources for a page in parallel, but it becomes a pitfall when individual failed requests should be tolerated and the results of the remaining, successful requests are still needed.

Promise.allSettled() solves exactly this problem by never rejecting itself, instead returning a result object for every passed promise with a status of fulfilled or rejected. The calling code then decides for itself how to handle individual failures, instead of a single failure automatically discarding all other results. Anyone who confuses these two methods, for example using Promise.all() for tolerant parallel processing, loses all already successfully loaded data from the remaining promises with every single failure.


const requests = [
  fetch('/api/users'),
  fetch('/api/orders'),
  fetch('/api/broken-endpoint') // simulate one failing request
];

// Promise.all: fail-fast, one rejection discards all other results
try {
  const results = await Promise.all(requests);
  console.log('All succeeded:', results.length);
} catch (error) {
  console.log('One failure discarded everything:', error.message);
}

// Promise.allSettled: every result is preserved, regardless of individual failures
const settled = await Promise.allSettled(requests);
const successful = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value);
const failed = settled.filter((r) => r.status === 'rejected').map((r) => r.reason);
console.log(`${successful.length} succeeded, ${failed.length} failed`);

6. Swallowing errors with empty catch blocks

An empty or nearly empty catch block is one of the most harmful forms of promise error handling, because it formally catches errors but completely destroys their information. A catch (error) {} with no action at all makes a failed operation look like a success from the outside, while the actual problem keeps festering somewhere in the system, often leaving inconsistent state behind. This kind of silent error handling is especially time consuming during debugging, because the original error has long since been discarded by the time it is investigated and left no trace in any log.

Even when an error should deliberately be ignored because it is uncritical in the given context, the catch block should explicitly document that decision and at least log the error, instead of letting it disappear without comment. A brief comment in the code explaining why a particular error case deliberately triggers no further action distinguishes a conscious design decision from a forgotten, unfinished catch block that a later developer would immediately question during code review.


// WRONG: silently swallows the error, no trace left for debugging
async function saveDraftSilent(draft) {
  try {
    await api.save(draft);
  } catch (error) {
    // nothing here — the failure is now invisible
  }
}

// RIGHT: log at minimum, even for errors considered non-critical
async function saveDraftSafe(draft) {
  try {
    await api.save(draft);
  } catch (error) {
    // Draft auto-save failures are non-critical, but must remain visible
    console.warn('Draft auto-save failed, will retry on next change:', error.message);
  }
}

7. async/await in loops: error handling per iteration

A try catch block wrapping an entire loop with multiple await calls aborts the whole loop on the first error, because the thrown error immediately hands control to the surrounding catch block and none of the remaining iterations are ever executed. That is correct when a single failure should invalidate the entire operation, but it becomes a pitfall when individual iterations are independent of each other and a failure in iteration three should not prevent iterations four and five from still being processed.

For independent iterations, promise error handling therefore belongs inside the loop body itself, with its own try catch block per pass that logs the error locally and then lets the loop continue. This distinction between all or nothing processing and independent per item processing is a deliberate design decision that should be made before writing the loop, rather than falling out randomly from the position of the try catch block.


const userIds = [1, 2, 3, 4, 5];

// WRONG (if independent processing is desired): one failure stops everything
async function processAllOrNothing(ids) {
  const results = [];
  try {
    for (const id of ids) {
      results.push(await fetchUser(id)); // any rejection aborts the whole loop
    }
  } catch (error) {
    console.error('Processing stopped at first failure:', error.message);
  }
  return results;
}

// RIGHT for independent items: catch inside the loop body, per iteration
async function processIndependently(ids) {
  const results = [];
  for (const id of ids) {
    try {
      results.push(await fetchUser(id));
    } catch (error) {
      console.warn(`Skipping user ${id}, fetch failed:`, error.message);
    }
  }
  return results; // contains every successful result, failures are just skipped
}

8. Global handlers: using the unhandledrejection event correctly

Both the browser and Node.js offer a global event for unhandled rejections, in the browser through window.addEventListener('unhandledrejection', ...), in Node.js through process.on('unhandledRejection', ...). This global handler is a valuable last safety net for monitoring and error logging in production, but it should never serve as a replacement for local promise error handling at the actual source of the error, because it loses the context in which the error originally occurred.

A sensible use of the global handler is wiring it into an error tracking system like Sentry, so that every unhandled rejection that slips through the system despite careful local error handling is at least captured and evaluated centrally. But the global handler does not replace good local promise error handling, it merely complements it as a fallback layer for the cases missed in code review or testing.


// Browser: global safety net, wired into an error tracking service
window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled promise rejection:', event.reason);
  // errorTrackingService.captureException(event.reason);
  event.preventDefault(); // optional: suppress the default browser console warning
});

// Node.js equivalent, useful in servers and background workers
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at:', promise, 'reason:', reason);
  // errorTrackingService.captureException(reason);
});

9. Error handling patterns compared side by side

Depending on the situation, different patterns for promise error handling fit best, and the choice depends heavily on whether a failure should affect the entire operation or only part of it.

Situation Common mistake Recommended pattern Why it works
Async call in try catch Forgotten await Always await before the async function catch only sees the error when synchronized
Fire-and-forget call No .catch attached .catch() even on intentionally unawaited promises Prevents unhandled rejections
Multiple independent requests Promise.all discards all results Promise.allSettled Successful results are preserved
Non-critical error Empty catch block At least log it, with a comment A deliberate decision stays traceable
Loop with independent items One failure aborts everything try catch inside the loop body Other iterations keep running

The table shows that good promise error handling is not a blanket rule, it requires a deliberate decision for every situation. Anyone who clarifies before writing the code whether a failure should affect the entire operation or only part of it avoids most of the pitfalls shown here from the start.

Mironsoft

JavaScript debugging, code reviews and frontend architecture

Are errors disappearing in your async code?

We audit existing code for risky promise error handling, close gaps from missing await calls and empty catch blocks, and set up monitoring for unhandled rejections.

Code Review

Targeted search for missing await and empty catch blocks

Refactoring

Promise.allSettled instead of Promise.all where it fits

Monitoring Setup

Wiring an unhandledrejection handler into error tracking

10. Summary

Promise error handling follows its own rules, fundamentally different from synchronous try catch, because an error inside a promise only becomes visible at a later point in time and must be actively picked up. The most common pitfall is a forgotten await that renders a try catch block useless, followed by unhandled rejections from fire and forget calls without .catch(), and empty catch blocks that silently destroy error information.

The choice between Promise.all() and Promise.allSettled() decides whether a single failure drags down every other result or whether successful partial results are preserved. In loops with independent iterations, error handling belongs inside the loop body itself, not around the entire loop. A global unhandledrejection handler complements good local promise error handling as a last safety net, but it never replaces it.

Promise Error Handling in JavaScript, the essentials at a glance

Base Rule

try catch only sees a promise error if it is correctly synchronized with await.

Fire-and-Forget

Even intentionally unawaited promises need their own .catch(), otherwise an unhandled rejection is created.

Parallel Requests

Promise.allSettled instead of Promise.all whenever individual failures should be tolerated.

Debugging

Never use empty catch blocks, always log at minimum, use a global unhandledrejection handler as a fallback layer.

11. FAQ: Promise Error Handling in JavaScript

1Why doesn't my try catch catch the error?
Await is probably missing before the function call, the block otherwise exits too early.
2What is an unhandled rejection?
A rejected promise with no attached catch handler, visible as a console warning or process termination in Node.js.
3Do I need catch for fire-and-forget?
Yes, even unawaited promises can fail and otherwise produce an unhandled rejection.
4Where does catch catch in a .then chain?
It catches errors from every preceding step, not only the immediately preceding one.
5Difference between Promise.all and allSettled?
all rejects immediately on one failure, allSettled always returns every result with a status.
6Why is an empty catch block problematic?
It completely destroys the error information, leaving no trace in the log for later diagnosis.
7How to handle errors in a loop with await correctly?
Put try catch inside the loop body, so only the affected iteration gets skipped.
8What does the unhandledrejection event do?
A global safety net for monitoring, but it does not replace good local error handling.
9Does try catch catch sync and async errors together?
Yes, as long as every async operation in the block is synchronized with await.
10Which ESLint rule helps against forgotten await?
no-floating-promises from the TypeScript ESLint plugin flags unawaited promises in the editor.