Promises are the backbone of modern JavaScript — they turn callback hell into readable chains and async/await syntax. But they have a quirk that catches every team: if a promise rejects and no catch handler exists, the rejection fails silently. Your flow breaks, your user sees a blank screen, and your error tracker never knows it happened. Understanding unhandled promise rejections is the difference between shipping robust async code and shipping surprises.
This guide explains why promises reject silently, how to detect rejections before they become production fires, and how to monitor them in production.
Why promises reject silently by design
A promise has three states: pending, resolved, or rejected. If a promise rejects — via .catch() with no handler, or an error thrown inside a promise constructor — the runtime's default behavior is to swallow it.
Here's the trap:
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json(); // If this fails, rejection swallows
return data;
}
// Called but never awaited, and no catch handler:
fetchUserData(123); // Silent rejection if fetch or json() fails
If that fetchUserData call fails and you're not awaiting it, the promise rejects, but the error vanishes. No stack trace, no console message, just a dead-silent failure. Your user's form doesn't submit, the page doesn't load, and you have no idea.
A promise only rejects silently if there's no .catch() handler and the rejection never propagates to an await or .then(). A rejected promise that is awaited will throw; a rejected promise that is neither awaited nor caught is the problem.
This design decision exists because promises can be held for later — you might attach a handler hours after creation. But in practice, it means your application is full of potential time bombs: async functions that fire-and-forget, race conditions, and missing error handlers.
The unhandledrejection event: your safety net
The browser and Node.js both fire an unhandledrejection event when a promise rejects with no handler. This is your hook to catch these silent failures before users see them.
In the browser:
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled rejection:", event.reason);
// Send to error tracker
});
In Node.js:
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection:", reason);
// Send to error tracker
});
The event fires before the rejection crashes your app, giving you a chance to log it, notify your error tracker, or even prevent the crash by calling event.preventDefault().
In modern browsers and Node.js 15+, an unhandled rejection will still crash your process or tab if you don't handle it. The event fires, but you must take action (log, report, or call preventDefault()) to prevent the crash.
Detecting unhandled promise rejections in your code
The best defense is not to have them in the first place. Here are the patterns that cause them:
Fire-and-forget async calls:
// Bad — no await, no catch
async function syncData() {
await fetch("/api/sync", { method: "POST" });
}
// Called without waiting
syncData(); // If fetch fails, rejection is silent
// Good — either await it or catch it
await syncData().catch(err => console.error(err));
Missing .catch() in promise chains:
// Bad
fetch("/api/data").then(res => res.json());
// Good
fetch("/api/data")
.then(res => res.json())
.catch(err => console.error("Fetch failed:", err));
Async functions in event handlers without error handling:
// Bad
button.addEventListener("click", async () => {
await fetch("/api/submit");
});
// Good
button.addEventListener("click", async () => {
try {
await fetch("/api/submit");
} catch (err) {
console.error("Submit failed:", err);
}
});
The pattern is consistent: every promise-returning call must either be awaited (so errors throw and you can catch them) or explicitly caught with .catch() or a try/catch block. If you can't do either, you've found a rejection waiting to happen.
Best practices for handling promise rejections
Once you've found the patterns, the fix is systematic:
-
Always chain .catch() on promise chains: A
.catch()at the end captures any rejection in the chain.fetch("/api/data") .then(res => res.json()) .then(data => updateUI(data)) .catch(err => { console.error("Data load failed:", err); showErrorUI(); }); -
Use try/catch with async/await: It's clearer and easier to reason about than nested .then() chains.
async function loadData() { try { const res = await fetch("/api/data"); const data = await res.json(); updateUI(data); } catch (err) { console.error("Data load failed:", err); showErrorUI(); } } -
Wrap event handlers: Async event handlers can reject silently. Wrap them explicitly.
button.addEventListener("click", async () => { try { await handleSubmit(); } catch (err) { // Report error Sentry.captureException(err); } }); -
Be explicit about fire-and-forget: If you truly don't care about a promise's result, call
.catch(() => {})to signal intent — and to prevent the error from swallowing.// Intentionally not awaited, rejection is intentionally ignored sendAnalytics().catch(() => {});
Monitoring rejections in production
Even with careful code review, rejections slip through. That's why error tracking exists. The Sentry SDK hooks unhandledrejection automatically — if you point it at LightTrace, every silent rejection becomes a grouped issue in your dashboard.
import * as Sentry from "@sentry/browser";
Sentry.init({
dsn: "https://<key>@your-lighttrace-host/1",
environment: "production",
});
// Now unhandledrejection events are captured and sent to LightTrace
With this in place, the unhandled rejection that would have failed silently instead lands as a stack trace with breadcrumbs and user context. You can see which promise rejected, the path that led there, and how many users were affected. That context is the difference between a wild debugging session and a five-minute fix.
You can also set up alert rules to page you when a new unhandled rejection appears, turning a silent failure into an immediate incident you can respond to.
The full picture: async errors in production
Unhandled promise rejections are just one flavor of the async errors that haunt JavaScript apps. They sit alongside uncaught errors, timeouts, race conditions, and CORS failures. The complete defense is:
- Write code that doesn't reject silently (use try/catch, always chain .catch()).
- Install a global unhandledrejection listener to catch what slips through.
- Instrument with an error tracker so production rejections become incidents, not mysteries.
Teams that do all three sleep better at night.
Start tracking errors in minutes
Point the Sentry SDK at LightTrace to turn every unhandled promise rejection into a grouped, debuggable issue — free up to 5,000 events a month.