How to Fix "Uncaught (in promise)" Errors in JavaScript
A promise rejected and nothing was listening. Here is what "Uncaught (in promise)" actually means, the patterns that cause it, and how to handle rejections so they stop surfacing as mystery crashes.
Uncaught (in promise) TypeError: ... is one of the most disorienting lines in the console, because the stack trace rarely points at the code you actually wrote. It points into the promise machinery, or into a line that looks completely fine, while the real bug is somewhere upstream — an await that never got a try/catch, a .then() chain missing its final .catch(), or an async function that fired and was never watched. This guide walks through what the error means, the handful of patterns that cause it, and the fixes that make rejections predictable instead of terrifying.
What "uncaught in promise" actually means
An unhandled promise rejection happens when a Promise settles as rejected — either because it threw, or something called reject() — and no .catch(), no rejection callback in .then(), and no try/catch around an await ever ran for it. The JavaScript engine has nowhere to route the error, so it reports it as "Uncaught (in promise)" and, in browsers, fires a global unhandledrejection event as a last resort.
This is different from a synchronous throw. A synchronous error unwinds the call stack immediately and, if nothing catches it, crashes right where it happened. A promise rejection is asynchronous: the engine has to wait a full microtask turn to see whether anything eventually attaches a handler before deciding the rejection is truly unhandled. Per MDN, the browser's unhandledrejection event fires precisely when a rejection reaches the end of that microtask turn with no handler attached. That delay is exactly why the error in the console so often looks disconnected from its cause — by the time it surfaces, the code that triggered it has already finished running.
The patterns that cause it
Almost every unhandled rejection traces back to one of these five shapes.
// Fire-and-forget: nobody is watching this promise
function saveDraft(note) {
api.post('/drafts', note); // returns a promise, ignored
}
// If the request fails, the rejection has no handler anywhere
saveDraft({ text: 'hello' });
// Fix: await it, or attach .catch() and mark the intent explicitly
async function saveDraft(note) {
try {
await api.post('/drafts', note);
} catch (err) {
console.error('Draft save failed', err);
}
}
// Or, if it's genuinely fire-and-forget:
void api.post('/drafts', note).catch((err) => reportError(err));// The chain has a .then() but no .catch() at the end
fetchUser(id)
.then((user) => renderProfile(user))
.then(() => trackEvent('profile_viewed'));
// If fetchUser rejects, or either .then() throws, it's unhandled
// Fix: always terminate a chain with .catch()
fetchUser(id)
.then((user) => renderProfile(user))
.then(() => trackEvent('profile_viewed'))
.catch((err) => {
console.error('Failed to load profile', err);
showErrorBanner();
});// Classic gotcha: fetch() only rejects on network failure, never on a bad status
async function loadInvoice(id) {
const res = await fetch(`/api/invoices/${id}`);
const data = await res.json(); // res could be a 404 with an HTML error page
return data;
}
// A 404/500 resolves fine, then .json() throws parsing garbage as JSON,
// and that throw becomes an unhandled rejection higher up the call chain
// Fix: check res.ok and throw explicitly so failures actually reject
async function loadInvoice(id) {
const res = await fetch(`/api/invoices/${id}`);
if (!res.ok) {
throw new Error(`Invoice request failed: ${res.status}`);
}
return res.json();
}Two more shapes worth naming because they are easy to write by accident:
- An
awaitthat isn't actually inside thetry: a stray line after the closing brace, or anawaitinside a callback that the outertry/catchdoesn't cover, defeats the whole point of the wrapper. - An async function whose caller doesn't await or catch it: marking a function
asyncturns everythrowinside it into a rejected promise. If the caller treats it like a synchronous function and never awaits or attaches a handler, that rejection has nowhere to go.
// Looks safe, but the await that can reject is OUTSIDE the try
async function submitForm(payload) {
let request;
try {
request = api.post('/forms', payload); // just creates the promise
} catch (err) {
console.error(err); // never runs for a rejection
}
return await request; // rejection happens here, uncaught
}
// Fix: the await itself has to be inside the try block
async function submitForm(payload) {
try {
return await api.post('/forms', payload);
} catch (err) {
console.error('Form submit failed', err);
throw err; // re-throw only if the caller also needs to react
}
}Cause → fix at a glance
The same five root causes map to a small set of fixes. The right-hand column is worth internalising: a linter can catch some of these mechanically, but the two most dangerous ones — a mishandled HTTP status and an await that sits outside its try — are perfectly valid code that no rule will flag for you.
| Feature | Correct fix | Caught by a linter? |
|---|---|---|
| Floating promise (created, never awaited, no .catch) | await inside try/catch, or void p.catch(handler) | Yes — no-floating-promises |
| Chain ends on a .then() with no final .catch() | Terminate the chain with .catch() | Partly — no-floating-promises on the returned chain |
| fetch() resolved a 4xx/5xx you never checked | Check res.ok and throw explicitly | — |
| await sits outside the try block that should cover it | Move the await inside the try | — |
| async function whose caller ignores the rejection | await or return the promise so a caller owns it | Yes — no-floating-promises |
Fixing it: try/catch, .catch(), and allSettled
Every fix comes down to making sure exactly one place is responsible for a promise's rejection. Wrap an await in try/catch when you can react to the failure right there — retry, show an error state, fall back to a default. End every .then() chain with a final .catch(), even if you think nothing can fail; APIs change, and a chain without one is a rejection waiting to happen the day it does. For a promise you deliberately don't want to await — logging an analytics event, warming a cache — attach .catch() and prefix the call with void so it reads as an intentional fire-and-forget rather than a forgotten await.
// Promise.all rejects the whole batch the instant ONE promise rejects,
// even if the other nine succeeded
const results = await Promise.all(userIds.map(fetchUser));
// One bad userId kills every result, and the failure reason for the
// other nine successful fetches is simply lost
// Fix: allSettled lets you inspect each outcome independently
const results = await Promise.allSettled(userIds.map(fetchUser));
const users = results
.filter((r) => r.status === 'fulfilled')
.map((r) => r.value);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length) {
console.warn(`${failed.length} of ${userIds.length} user fetches failed`);
}async/await vs .then(): two ways to catch the same rejection
A rejection is a rejection no matter which syntax created it, but the two styles put the handler in different places, and mixing them is where people slip. With async/await, a rejected promise behaves like a thrown exception at the await point, so a surrounding try/catch catches it — the same construct you'd use for synchronous errors. With .then() chains, there is no exception to catch synchronously; you attach a rejection handler either as the second argument to .then(onFulfilled, onRejected) or, better, as a trailing .catch() that also covers throws inside the earlier .then() callbacks.
// async/await: the await throws, try/catch handles it
async function load() {
try {
const user = await fetchUser(id);
return renderProfile(user);
} catch (err) {
showErrorBanner(err);
}
}
// .then(): the trailing .catch() handles a reject from fetchUser
// AND any throw inside the .then() callback above it
function load() {
return fetchUser(id)
.then((user) => renderProfile(user))
.catch((err) => showErrorBanner(err));
}
// Gotcha: the two-argument form does NOT catch a throw
// inside its own onFulfilled callback
fetchUser(id).then(
(user) => renderProfile(user), // if THIS throws, the onRejected below misses it
(err) => showErrorBanner(err),
);The global safety net: unhandledrejection
Even in a well-handled codebase, something will eventually slip through — a third-party library's internal promise, a rare timing edge case, a rejection from code you don't control. The unhandledrejection event on window (and the equivalent on WorkerGlobalScope inside Web Workers) fires whenever a rejection reaches the end of a microtask turn with no handler attached. The event is cancelable: its event.reason holds whatever the promise rejected with, event.promise is the promise object itself, and calling event.preventDefault() stops the browser's default console logging. MDN also documents a companion rejectionhandled event that fires if a handler is attached later, after the rejection was already reported.
window.addEventListener('unhandledrejection', (event) => {
// event.reason is whatever the promise rejected with
// event.promise is the Promise object itself
console.error('Unhandled rejection:', event.reason);
reportToErrorTracker({
message: event.reason?.message ?? String(event.reason),
stack: event.reason?.stack,
});
// Optional: prevent the default "Uncaught (in promise)" console log
// once you're confident you're reporting it yourself elsewhere
event.preventDefault();
});In Node.js, an unhandled rejection can crash the process
On the server the stakes are higher. Node.js exposes the same failure through the 'unhandledRejection' process event, which is emitted whenever a promise is rejected and no handler is attached within a turn of the event loop; the listener receives the reason and the rejected promise. What changed in Node.js 15 is the default consequence: as of PR #33021, the default --unhandled-rejections mode became throw, meaning an unhandled rejection is re-raised as an uncaught exception and terminates the process with a non-zero exit code. Before v15 it merely printed an UnhandledPromiseRejectionWarning.
The --unhandled-rejections=mode flag lets you choose the behavior: throw (default — emit the event, then throw as uncaught if unhandled), strict (always throw, even with a listener attached), warn (warn but keep running), warn-with-error-code (warn and set a non-zero exit code without crashing), and none (silence it entirely). Don't reach for warn or none to make the message go away — a rejection you ignore on a request handler is a bug you'll pay for later. Keep a process.on('unhandledRejection', ...) listener for structured logging, then fix the missing .catch() upstream.
// Log the reason before Node's default 'throw' behavior tears the process down
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
reportToErrorTracker(reason);
// Intentionally NOT swallowing it: let the process exit and let your
// supervisor (pm2, systemd, k8s) restart a clean worker.
});
// Since Node 15 this crashes the process by default:
Promise.reject(new Error('boom'));
// $ node app.js -> throws, exits with a non-zero codeDebugging it in Chrome DevTools
When the console line doesn't tell you which promise rejected, DevTools can. In the Sources panel, the Pause on exceptions breakpoint stops execution the moment an exception is thrown — tick "Pause on caught exceptions" too and the debugger will halt inside the rejecting async function, at the real throw, before the rejection has floated up into the opaque "(in promise)" report. To follow the trail across the await boundary, enable the Async call stack checkbox next to the Call Stack pane; DevTools then stitches the pre-await frames onto the current stack for promises, timers, XMLHttpRequest, and event listeners, so you can see the click handler that kicked off the request that eventually rejected.
Framework gotcha: React error boundaries don't catch this
If you're in React and expecting an error boundary to catch a rejected fetch inside a click handler or a useEffect, it won't. The React docs are explicit that error boundaries do not catch errors in event handlers or asynchronous code — they only catch what throws during rendering, in lifecycle methods, and in constructors. A promise rejection in an event handler runs outside the render cycle entirely, so it sails past the boundary and lands in the console as "Uncaught (in promise)." The fix is the same one this whole guide is about: a try/catch around the await in the handler, or a .catch() on the chain, and a state update to show the error UI yourself. (Newer patterns like the use hook, which unwrap a promise during render, do surface to a boundary — but a fetch fired from a handler never will.)
Why this error is so hard to reproduce
Unhandled rejections are notoriously slippery in testing for two reasons. First, the crash often surfaces disconnected from its cause: a click handler kicks off an async save, the handler itself returns immediately and looks fine, and the rejection appears in the console moments later with a stack trace that no longer matches what the user was doing. Second, an unhandled rejection frequently doesn't crash the page at all — the UI keeps rendering, the click still "worked" from the user's point of view, and the only evidence is a red line in a console nobody was watching. That combination means these errors pile up quietly in production long before anyone notices a pattern.
Caveats: when the global net is the wrong tool
A few honest limits are worth stating. A global unhandledrejection or process.on('unhandledRejection') listener is a reporting backstop, not a control-flow mechanism — you cannot retry a request or recover state from it, because by the time it fires the rejection is already loose and you often can't tell which call site produced it. Silencing rejections with event.preventDefault() or Node's --unhandled-rejections=none hides real bugs and should be reserved, if ever, for a rejection you have positively identified and decided is benign. Promise.allSettled is the right tool only when partial failure is genuinely acceptable; if any one result is required, Promise.all with a proper .catch() is more honest. And catching a rejection is not the same as handling it — an empty catch {} that swallows the error is arguably worse than the original warning, because now the failure is both broken and invisible. The goal throughout is one owner per promise, and a real decision at that owner about what failure means.
Install the free BugMojo extension and capture the exact session — replay, console, and network — when the rejection fires, so you see the real promise chain and API response instead of reconstructing it from a bare stack trace.
Install the free extensionFrequently asked questions
Frequently asked questions
Sources
- Window: unhandledrejection event — MDN reference for catching promise rejections with no other handler — MDN Web Docs (2026)
- PromiseRejectionEvent — MDN reference for the event object passed to unhandledrejection handlers, including the .promise and .reason properties — MDN Web Docs (2026)
- Promise.prototype.catch() — MDN reference for attaching a rejection handler to a promise chain — MDN Web Docs (2026)
- Process — Node.js docs for the 'unhandledRejection' event and its reason/promise arguments — Node.js (2026)
- Command-line API — Node.js docs for the --unhandled-rejections=mode flag and its throw/strict/warn/none modes — Node.js (2026)
- process: Change default --unhandled-rejections=throw (PR #33021) — the change that made unhandled rejections crash the process by default in Node.js 15 — nodejs/node on GitHub (2020)
- Automatically pause on any exception — Chrome DevTools guide to pausing on caught and uncaught exceptions, including promise rejections — Chrome for Developers (2024)
- Debugging asynchronous JavaScript with Chrome DevTools — async call stacks across promises, timers, and events — Chrome for Developers (2024)
- Error Boundaries — React docs stating boundaries do not catch errors in event handlers or asynchronous code — React (2024)
- Top 10 JavaScript errors from 1,000+ projects (and how to avoid them) — real-world frequency data on JavaScript exceptions including promise-related errors — Rollbar (2026)
Get bug-tracking insights, weekly.
Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.

