Uncaught Promises — The Bug Your Users See but Your Logs Don't
Picture this: a pilot user opens your app for the first time, clicks through the onboarding flow you demoed perfectly yesterday, and hits a blank screen.
Picture this: a pilot user opens your app for the first time, clicks through the onboarding flow you demoed perfectly yesterday, and hits a blank screen. Nothing loads. They wait, refresh, give up. Back on your end, the dashboard is quiet — no errors flagged, no alerts firing, server response times nominal. Your logs say nothing. Your user just churned. That gap between what your monitoring shows and what your user experienced is the exact problem this piece is about, and it has a specific name.
Uncaught promise rejections are the mechanism. Every time an async operation in your app fails without a handler to catch it — a network fetch that times out, a database read that returns nothing, an API call that misfires — the UI freezes or goes blank while every server-side monitor you have reports a clean run. Three ordered fixes close that gap. None of them requires rewriting the features that already work.
Your app looks fine in the logs — so why do users keep hitting a blank screen?
An uncaught promise rejection is an async operation that failed silently. The UI stops updating — blank screen, frozen spinner, empty list — while your server-side monitoring sees no error because the failure never reached the server. It happened in the browser, in JavaScript, and it died there. Your dashboard is clean. Your user is gone.
Modern web apps do almost everything asynchronously. Fetching a user's data, reading from a database, calling a payment API — each of those is a promise, a piece of code that says "I'll get back to you with a result." When that promise is rejected — the fetch times out, the API returns a 500, the database query finds nothing — and there is no rejection handler in place to catch it, JavaScript swallows the failure. No exception thrown. No log entry written. The UI simply stops.
The reason this is so disorienting for founders is the false signal. Your error monitoring shows zero. Your uptime check passes. You refresh the dashboard and conclude the app is healthy. It is not. There is a class of failure that only your users can see, and they are seeing it every time one of those unhandled async operations fires in the wrong conditions.
That mismatch — silent failure in the client, clean signal in the server — is what makes uncaught promise rejections the most common diagnostic blind spot in early-stage apps.
Vibe-coded MVPs are built to win demos, not survive edge cases — and that gap shows up here first
AI-generated code is structurally prone to this failure because code generators optimize for the happy path. The demo works because the demo always has good data, fast network, and cooperative APIs. Error-handling boilerplate on async calls is not what gets a demo to "wow" — so generators skip it.
When you vibe-code an MVP, every network fetch, every database read, every third-party API call that lacks a rejection handler becomes a loaded silence. It works fine in the demo environment. It works fine in your own testing, where you know which buttons to press in which order. Then a pilot user does something slightly off-script — a slow mobile connection, an account with no historical data, an API that rate-limits on the first request — and the promise rejects with nothing to catch it.
This is not a criticism of using AI to build the MVP. Getting to a working product quickly, at low cost, with features users can actually touch — that is a real and legitimate advantage. The issue is structural: the same speed that produced your MVP also skipped the error-handling layer that keeps a product stable when real users, on real networks, in real conditions, test the edges your demo never reached.
Solo founders and engineering teams inheriting vibe-coded codebases both run into this failure at the same moment — when pilots begin and the happy path stops being the only path.
How do you find an error your own logs will never surface?
Detection requires two steps, and neither needs infrastructure changes. Open your browser's developer console and filter for "Unhandled Promise Rejection." Then add a single global event listener for the unhandledrejection event — this catches every future silent failure and writes it to a log you control.
Step one is immediate and costs nothing. Open Chrome DevTools or Firefox Developer Tools, go to the Console tab, and type "unhandled" in the filter field. Trigger every user flow you can — onboarding, data load, payment, settings update. Any unhandled promise rejection that fires will surface here, with the exact call stack that caused it. You will likely see failures you did not know existed.
Step two makes that visibility permanent. A single block of JavaScript, added once at the top level of your app, listens for the unhandledrejection event on the window object. Every time a promise rejects without a handler, that listener fires and writes the error — the message, the stack trace, the promise that failed — to whatever logging destination you already use. One addition. No new tools, no new services, no changes to your existing features.
Together, these two steps answer a question that your current monitoring cannot: what is actually failing for your users right now?
Three fixes that close the gap before your next pilot demo
The three repairs are additive and sequential. The global rejection handler is your immediate safety net — it catches failures that already exist in the codebase. Wrapping existing async calls in try/catch blocks is the structural fix — it handles errors where they originate. A no-floating-promises linting rule is the enforcement layer — it prevents the pattern from recurring as you add features.
Fix one: global rejection handler. The event listener described above is not just a detection tool — it is also a safety net. Once it is in place, you can route caught rejections to a user-facing message ("Something went wrong — try refreshing") instead of letting the UI go blank. This does not fix the underlying error. It does mean your user sees a recoverable state rather than nothing.
Fix two: try/catch on existing async calls. Work through the async calls your app makes — the fetches, the database reads, the API calls — and wrap each one in a try/catch block. The try block contains the operation; the catch block handles the failure explicitly, whether that means showing an error state, retrying, or logging and moving on. This is the structural fix. It handles errors at their source rather than at the global level.
Do not try to do this everywhere at once. Prioritize the calls that touch your pilot's core workflow — the actions they will perform in their first session. Three to five async calls, handled correctly, eliminate the most likely failure points before your next demo.
Fix three: no-floating-promises linting rule. ESLint's @typescript-eslint/no-floating-promises rule — or its equivalent in your stack — flags any async call that is not awaited and not explicitly handled. Wire this rule into your editor so it surfaces at write-time, not at deploy-time. Every time you or a contractor adds a new async call without a rejection handler, the editor flags it before it reaches a user.
Each fix is additive. None requires touching features that already work. Implement them in order, and you have a safety net, a structural repair, and an enforcement layer — in roughly an afternoon of focused work.
How do you stop this bug from coming back every time you add a feature?
One linting rule, wired into your save-and-commit workflow, catches new uncaught promises at write-time — before they reach users. The rule costs nothing to maintain once it is in place. It turns a recurring fire-drill into a passive guardrail.
The pattern that created your current uncaught rejections will recreate itself every time you add a feature. New async call, no rejection handler, works in the demo, fails in the edge case. The only way to break that cycle without slowing down development is to catch the pattern at the moment it is written.
A linting rule in your editor does exactly that. Configure it once, commit the configuration file to your repository, and every future async call that lacks a handler produces an inline warning — red underline, clear message — before the code is saved. Your contractor sees it. You see it. The CI/CD pipeline, the automated process that runs checks before code is merged, can be configured to reject the change entirely if the rule fires.
This is what "keep the speed, add the rigor" looks like in practice. You are not adding a review process that slows down shipping. You are adding a check that runs in milliseconds, invisibly, every time a line of code is written. The guardrail is passive. The development speed stays the same.
Three days of focused work — handler, try/catch on the critical path, linting rule enabled. No rebuild.