Fire and Forget: The Promise Rejection Bug That's Already Burning Down Your App
Somewhere in your codebase right now, there's a promise that's going to reject. Maybe it already has. And if you didn't chain a .catch() onto it — or if you await-ed it inside a function that nobody wrapped in a try/catch — that rejection is going to evaporate into the JavaScript event loop like it never happened.
No log entry. No Sentry alert. No PagerDuty notification at 3 AM. Just... silence. And a production system that's either quietly broken or about to crater completely.
This isn't a hypothetical. This is a category of bug that's taken down real applications, corrupted real data, and left real engineering teams staring at dashboards that show a perfectly healthy system while users are filing support tickets about features that stopped working hours ago.
How Promise Rejections Actually Escape
Here's the thing about unhandled promise rejections: JavaScript doesn't care that you forgot. The runtime will dutifully execute your async code, hit the rejection, look around for a handler, find nothing, and then... move on. In older versions of Node.js, it would print a deprecation warning to stderr and keep running. Cheerfully. Like nothing happened.
That behavior changed in Node 15, where unhandled rejections finally started terminating the process by default. Which sounds like an improvement — and it is — except that it introduced a new class of production incident where your Node server just dies with no obvious explanation and your process manager quietly restarts it before anyone notices.
In the browser, the behavior is even more forgiving, which is to say: even more dangerous. An unhandled rejection in a browser fires a unhandledrejection event on the window object. If nothing is listening for that event — and most apps aren't, by default — it disappears. Your error tracker doesn't see it. Your user doesn't see it. You don't see it until someone complains that the checkout button stopped working.
The Patterns That Bite Hardest
There are a few specific patterns that show up over and over in post-mortems.
The orphaned async call. You fire off an async operation — maybe logging something, maybe sending an analytics event — and you don't await it because you don't care about the result. Totally reasonable. Except that function can reject, and since nobody's holding a reference to that promise, the rejection goes unhandled. The fix is almost insultingly simple: .catch(() => {}) or a wrapper that swallows errors intentionally. But you have to know to do it.
The async event handler. You attach an async function as an event listener — a click handler, a message event in a worker, an Express route handler that someone refactored to async without thinking it through. When that function throws, the framework has no idea what to do with the resulting rejected promise because it was never designed to handle one. Express, famously, will just let the rejection sit there until Node decides what to do with it. This one has caused a lot of production incidents.
The Promise.all trap. You're running several async operations in parallel with Promise.all, which is great. But if you're not careful about how you handle the rejection of the entire batch, a single failure in one of those operations can leave the others in a weird intermediate state — partially executed, with side effects already applied — while your error handler only sees the rejection, not the carnage that preceded it.
The forgotten finally. You wrote the .catch(), you're feeling good about yourself, and then three months later someone refactors the function and accidentally removes it. Or the .catch() itself throws an error. Congratulations, you now have a rejected promise inside your rejection handler.
Why Your Monitoring Is Probably Blind to This
Most error tracking tools — Sentry, Datadog, Bugsnag, take your pick — work by hooking into thrown exceptions and console errors. Unhandled promise rejections require a separate integration, and it's one that a surprising number of teams either skip or misconfigure.
In the browser, you need to explicitly listen for unhandledrejection on window and pipe those events into your error tracker. In Node, you need to handle the unhandledRejection event on process. Both of these are well-documented. Neither of them is set up by default in a fresh project.
The result is a monitoring blind spot the size of a truck. You can have a system that's rejecting dozens of promises an hour and your error dashboard will show a flatline. Everything looks fine. Everything is not fine.
What a Real Incident Looks Like
Here's a war story that'll sound familiar if you've been doing this long enough.
A mid-sized SaaS company — call them Company X — shipped a refactor of their notification service. The developer who did the refactor converted several callback-based functions to async/await, which was the right call. But one of those functions was being called inside a setInterval loop, and the async wrapper meant that any rejection inside it would go unhandled.
For two weeks, nothing happened. The error rate looked normal. Then a third-party email API they were using started intermittently returning 429s. The notification function started rejecting. The rejections went nowhere. The interval kept firing. Users stopped getting emails. The support queue filled up. The team spent three days looking at the wrong part of the system before someone thought to check the Node process logs and found the unhandledRejection events that had been printing to stderr — on a server where nobody was watching stderr.
Two weeks of silent failure. Three days of debugging. All because of a missing .catch().
How to Actually Fix This
The short answer: treat unhandled rejections like the production incidents they are.
Set up global handlers for unhandledRejection in Node and unhandledrejection in the browser, and make sure they route to your error tracker. Most major SDKs have explicit documentation on this — use it.
Lint for it. ESLint has rules like no-floating-promises (via @typescript-eslint) that will flag promises that aren't being awaited or caught. Turn them on. Set them to error, not warn.
For async event handlers in Express or similar frameworks, use a wrapper that catches rejections and passes them to next(). It's a one-time fix that saves you from a whole category of incidents.
And if you're using Promise.all, consider Promise.allSettled when you need to handle partial failures gracefully. It won't reject the whole batch on a single failure, which means you get to decide what to do with each result individually.
None of this is complicated. The hard part is remembering to do it before the 2 AM page, not after.
The Error That Doesn't Announce Itself
Most bugs make noise. They throw exceptions, they print to the console, they trigger alerts. Unhandled promise rejections are different. They're the bug that learned to be quiet — that slides past your defenses not by being clever, but by exploiting the gap between how async JavaScript works and how most developers assume it works.
Your monitoring tools are probably not catching them. Your tests are probably not covering them. And somewhere in your codebase, there's almost certainly a promise that's going to reject at exactly the wrong moment.
The good news is that fixing this is genuinely straightforward once you know where to look. The bad news is that you have to look.