How to Handle Promise Rejections in JavaScript

Learn every technique for handling Promise rejections in JavaScript: .catch(), try/catch with async/await, the unhandledrejection event, rejection chaining, and best practices to prevent silent failures.

JavaScriptintermediate
12 min read

Unhandled Promise rejections are one of the most common sources of silent bugs in async JavaScript code. A Promise that rejects without a handler fails invisibly — no error in the console (in older environments), no crash, just incorrect behavior. This guide covers every technique for correctly handling rejections, from basic .catch() to global handlers and advanced patterns.

What Is a Promise Rejection?

A Promise rejects when:

  1. The executor function calls reject(reason)
  2. An error is thrown inside the executor
  3. A .then() handler throws an error
  4. A .then() handler returns a rejected Promise
javascriptjavascript
// Rejection via reject()
const p1 = new Promise((resolve, reject) => {
  reject(new Error("Something went wrong"));
});
 
// Rejection via thrown error inside executor
const p2 = new Promise(() => {
  throw new Error("Thrown in executor");
});
 
// Rejection propagated from .then() handler
const p3 = Promise.resolve(1)
  .then((n) => {
    if (n < 10) throw new Error("Too small");
    return n;
  });

All three produce rejected Promises. The rejection reason is usually (and should always be) an Error object.

Technique 1: .catch() Method

.catch(handler) is the standard way to handle rejections in a Promise chain:

javascriptjavascript
fetch("https://api.example.com/data")
  .then((response) => response.json())
  .then((data) => processData(data))
  .catch((error) => {
    // Handles any rejection from fetch, response.json(), or processData
    console.error("Request failed:", error.message);
  });

.catch() is shorthand for .then(undefined, handler). The key feature: it catches rejections from ANY earlier step in the chain, not just the immediately preceding one.

.catch() Returns a Promise

The Promise returned by .catch() is fulfilled (not rejected) if the handler runs without throwing:

javascriptjavascript
Promise.reject(new Error("network error"))
  .catch((err) => {
    console.error(err.message); // "network error"
    return "default value";    // Recovered! Chain continues
  })
  .then((value) => {
    console.log(value);        // "default value"
  });

This enables the recovery pattern — falling back to a default or retrying on failure.

Re-Throwing When You Cannot Recover

javascriptjavascript
fetch(url)
  .then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .catch((err) => {
    if (err.message.startsWith("HTTP 5")) {
      // Server error: log but re-throw (cannot recover here)
      logError(err);
      throw err;
    }
    // Other errors: swallow and return fallback
    return fallbackData;
  })
  .then((data) => renderUI(data));

Technique 2: try/catch with async/await

When using async/await, wrap await expressions in a try/catch:

javascriptjavascript
async function loadDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const stats = await fetchStats(user.id);
    const posts = await fetchRecentPosts(user.id);
    renderDashboard({ user, stats, posts });
  } catch (error) {
    // Catches rejection from ANY of the three fetch calls above
    showErrorMessage(error.message);
  }
}

Granular try/catch for Different Errors

Sometimes different operations require different error handling:

javascriptjavascript
async function initApp() {
  // Config failure: fatal, cannot continue
  let config;
  try {
    config = await loadConfig();
  } catch (err) {
    console.error("Cannot load config, aborting:", err);
    process.exit(1);
  }
 
  // Auth failure: redirect to login
  let user;
  try {
    user = await authenticateUser();
  } catch (err) {
    redirectToLogin();
    return;
  }
 
  // Data failure: show stale or default data
  let data;
  try {
    data = await fetchDashboardData(user.id);
  } catch (err) {
    console.warn("Using cached data:", err.message);
    data = loadCachedData();
  }
 
  renderApp(config, user, data);
}

Wrapping await Without try/catch

For cases where you want inline error handling without try/catch blocks, you can use a helper:

javascriptjavascript
// Helper: returns [error, result] tuple
async function attempt(promise) {
  try {
    const result = await promise;
    return [null, result];
  } catch (err) {
    return [err, null];
  }
}
 
// Usage:
async function getUser(id) {
  const [err, user] = await attempt(fetchUser(id));
  if (err) {
    console.error("Failed to fetch user:", err.message);
    return null;
  }
  return user;
}

This pattern (inspired by Go-style error handling) keeps error handling visible without deeply nested try/catch.

Technique 3: The Second Argument to .then()

.then(onFulfilled, onRejected) accepts a rejection handler as its second argument. This is primarily useful when you want to handle a specific step's rejection differently from the rest of the chain:

javascriptjavascript
fetchPrimaryData()
  .then(
    (data) => processData(data),
    (err) => {
      // Only handles rejection from fetchPrimaryData
      // Does NOT catch errors thrown by processData
      return fallbackData;
    }
  )
  .then((result) => saveResult(result))
  .catch((err) => handleFinalError(err)); // Catches processData and saveResult errors

This is a subtle but important difference from .then(a).catch(b) — see the FAQ for full comparison.

Technique 4: The unhandledrejection Event

When a rejected Promise has no handler, the unhandledrejection event fires on window (browsers) or process (Node.js):

javascriptjavascript
// Browser: global unhandled rejection handler
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled rejection:", event.reason);
  event.preventDefault(); // Suppress default console warning in some browsers
 
  // In production: send to error tracking
  Sentry.captureException(event.reason);
});
 
// Node.js: process-level handler
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise, "reason:", reason);
  // In production: log and potentially exit
  process.exit(1);
});

When Does unhandledrejection Fire?

javascriptjavascript
// This fires unhandledrejection (no .catch())
Promise.reject(new Error("oops"));
 
// This does NOT fire unhandledrejection (has .catch())
Promise.reject(new Error("oops")).catch(() => {});
 
// This does NOT fire — .catch() attached eventually (within same microtask tick)
const p = Promise.reject(new Error("oops"));
p.catch(() => {}); // Attaching .catch() synchronously prevents the unhandled event

The rejectionhandled Event

When a previously unhandled rejection gets a handler later, rejectionhandled fires:

javascriptjavascript
window.addEventListener("rejectionhandled", (event) => {
  console.log("Promise rejection was handled after the fact:", event.reason);
});

Rejection Chaining Patterns

Failing Fast (Default)

javascriptjavascript
// One bad step stops the chain
Promise.resolve()
  .then(() => stepA())         // If this rejects...
  .then(() => stepB())         // ...this is skipped
  .then(() => stepC())         // ...and this
  .catch((err) => handleError(err)); // Only this runs

Continue on Partial Failure

javascriptjavascript
// Run all steps but collect errors
async function runAllSteps(items) {
  const results = [];
  const errors = [];
 
  for (const item of items) {
    try {
      const result = await processItem(item);
      results.push({ item, result, success: true });
    } catch (err) {
      errors.push({ item, error: err.message, success: false });
    }
  }
 
  return { results, errors };
}

Retry on Rejection

javascriptjavascript
async function fetchWithRetry(url, maxAttempts = 3) {
  let lastError;
 
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (err) {
      lastError = err;
      if (attempt < maxAttempts) {
        const delay = attempt * 1000; // Incremental backoff
        console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
  }
 
  throw new Error(`All ${maxAttempts} attempts failed: ${lastError.message}`);
}

Rejection Handling With Promise.all

Promise.all rejects immediately if any Promise rejects (fail-fast). Use Promise.allSettled when you want results from all Promises regardless of outcome:

javascriptjavascript
const ids = [1, 2, 3, 4, 5];
 
// Promise.all: one failure cancels all
try {
  const users = await Promise.all(ids.map((id) => fetchUser(id)));
  // All or nothing
} catch (err) {
  console.error("At least one user fetch failed:", err);
}
 
// Promise.allSettled: get results from all, handle errors individually
const results = await Promise.allSettled(ids.map((id) => fetchUser(id)));
const succeeded = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
const failed = results.filter((r) => r.status === "rejected").map((r) => r.reason);
 
console.log(`${succeeded.length} succeeded, ${failed.length} failed`);

For more on Promise.all and related methods, see how to use Promise.all.

Best Practices Table

PracticeDo ThisNot This
Always handle rejections.catch() or try/catch on every Promise chainLet Promises reject silently
Rejection reasonsreject(new Error("..."))reject("string error")
Re-throw unknown errorsthrow err in .catch() if you can't handle itSilently swallow all errors
Global fallbackSet unhandledrejection handler in app startupRely on default browser behavior
Granular error typesCustom Error subclasses for categorizationGeneric new Error() for everything
Rune AI

Rune AI

Key Insights

  • Never let a Promise chain end without .catch(): A rejected Promise with no handler fails silently; one .catch() at the end of a chain covers all preceding steps
  • .catch() recovers by returning a value: Unless the .catch() handler re-throws, the chain continues in a fulfilled state with the handler's return value
  • async/await uses try/catch: await causes a rejected Promise to throw, so standard try/catch blocks work exactly as with synchronous code
  • unhandledrejection is a global safety net: Register it at app startup to catch any rejections that slipped through, especially in production where silent failures are invisible
  • Reject with Error objects, not strings: Error objects include stack traces; strings do not — always throw new Error("message") or reject(new Error("message")) for debuggable failures
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between .then(a, b) and .then(a).catch(b)?

In `.then(a, b)`, the rejection handler `b` does NOT catch errors thrown by `a`. In `.then(a).catch(b)`, if `a` throws, `b` catches it. For most cases, the `.then(a).catch(b)` pattern is safer. Use `.then(a, b)` only when you intentionally want different handlers for the success and failure of a specific step without the success handler's errors being caught by the failure handler.

Should I throw Error objects or plain strings in rejections?

lways use `new Error("message")`. Error objects provide a stack trace which is essential for debugging. Plain strings (`reject("failed")`) have no stack trace and make it much harder to determine where the error originated.

Does every Promise need its own .catch()?

No. In a chain, one `.catch()` at the end handles all preceding rejections. For independent Promises (not part of a chain), each needs its own handler unless you are collecting them via `Promise.allSettled`. The global `unhandledrejection` handler is a safety net, not a substitute.

What happens to a rejected Promise if the garbage collector cleans it up before a handler is attached?

In modern browsers and Node.js, this situation triggers `unhandledrejection`. The timing of the unhandled rejection check varies — most implementations give one microtask cycle for you to attach a handler before firing the event. In practice, always attach handlers synchronously after creating or receiving a Promise.

Can I silence unhandledrejection warnings intentionally?

Yes: `Promise.reject(reason).catch(() => {})` explicitly attaches an empty handler. Use this only when you genuinely have a fire-and-forget Promise where you expect it might fail and the failure is acceptable. Always document why you are intentionally ignoring a rejection.

Conclusion

Promise rejections must always be handled — unhandled rejections are silent failures that cause incorrect application behavior. The primary tools are .catch() for chain-based code and try/catch for async/await code. The global unhandledrejection event is a safety net for unexpected rejections that slip through. For parallel operations, Promise.allSettled handles partial failure gracefully while Promise.all uses fail-fast semantics. Understanding Promise chaining provides the foundation for placing error handlers correctly in any chain.