Strict TypeScript is passing. CI is green. The code deploys. Two hours later, a Node.js worker crashes because a payment API call rejected and nothing caught it. Or a React component freezes on a loading spinner because a data fetch failed silently and the error state never triggered.
The common thread: a Promise that rejected without anyone observing the rejection. TypeScript checked the types. The compiler was satisfied. But the runtime was not.
Unhandled promises are one of the most common sources of production failures in TypeScript codebases. They pass every static check, survive code review, and then fail under exactly the conditions that matter most: network errors, timeouts, and downstream service failures.
TL;DR
- Unhandled promises happen when async work is started but not awaited, caught, or intentionally managed.
- TypeScript checks types, not runtime Promise handling. A floating promise is type-safe but runtime-unsafe.
- In browsers, this causes silent UI failures: spinners that never resolve, error states that never trigger.
- In Node.js, unhandled rejections can terminate the process in modern versions.
For the broader picture on what TypeScript's compiler does and does not catch, see Beyond TSC: Advanced Static Analysis for TypeScript.
The real-world cost: why one missing await becomes an outage
Consider a Node.js service that processes subscription renewals. On each renewal, it charges a payment provider, then sends a confirmation email:
async function renewSubscription(userId: string) {
await chargePaymentProvider(userId);
sendConfirmationEmail(userId); // <-- no await
}The payment charge is awaited. But sendConfirmationEmail returns a Promise that nobody observes. If the email provider is down, that Promise rejects. The rejection is not caught by any surrounding try/catch because the function has already returned. Depending on the Node.js version and configuration, the process may log a warning, throw an uncaught exception, or terminate entirely.
The service processes thousands of renewals per hour. One downstream outage turns a minor email failure into a cascading process crash that stops all payment processing. The root cause: a single missing await.
This is not a code-style issue. It is a production reliability issue.
The three ways unhandled promises break production
A) Fatal Node.js process failure
In older Node.js versions, unhandled promise rejections produced a deprecation warning. In modern Node (15+), the default behaviour is far stricter: an unhandled rejection is treated as an uncaught exception, which terminates the process.
Global handlers like process.on('unhandledRejection', ...) are useful for logging and coordinating a clean shutdown. But they are not a license to continue normal execution. After an unhandled rejection, the process state may be compromised: transactions may be half-committed, connections may be leaked, and in-flight requests may be in an inconsistent state.
B) Silent UI failures
In front-end applications, a rejected Promise that bypasses the intended error-handling path means the UI never transitions from its loading state to an error state. The spinner stays visible. The data never renders. The user sees a broken workflow with no useful feedback.
These bugs are especially hard to reproduce in development because they only manifest when a network request actually fails — something that happens rarely on a developer's local machine but constantly in production.
C) Orphaned async work and skipped cleanup
A floating promise can outlive the surrounding request or component lifecycle. In a server context, an unawaited database call may continue executing after the HTTP response has already been sent. In a React component, an unawaited fetch may resolve after the component has unmounted.
Cleanup logic — closing connections, cancelling timers, releasing locks — may never run where the developer expects. Resources, pending operations, and background work continue beyond their correct boundary. This creates hard-to-debug production instability that worsens under load.
The code smells: common mistakes and their fixes
Mistake 1: The fire-and-forget trap
Bad
sendWelcomeEmail(user.id);
Why it fails: sendWelcomeEmail returns a Promise. If it rejects, nothing handles the rejection. The error escapes into the runtime.
Good
// Option A: await it await sendWelcomeEmail(user.id); // Option B: intentionally detached with explicit error handling void sendWelcomeEmail(user.id).catch(logger.error);
Why the fix works: Option A awaits the Promise, so any rejection flows into the surrounding try/catch or caller. Option B uses void to signal intentional detachment and attaches .catch() so the rejection is observed.
Mistake 2: Missing await inside try/catch
Bad
try {
return fetchUserData();
} catch (error) {
handleError(error);
}Why it fails: returning the Promise without await means the rejection happens after the try block has already exited. The catch clause never sees the error.
Good
try {
return await fetchUserData();
} catch (error) {
handleError(error);
throw error;
}Why the fix works: adding await ensures the rejection is thrown inside the try block where catch can observe it. Re-throwing preserves the error for the caller.
Mistake 3: Promise.all fail-fast assumptions
Bad
const results = await Promise.all([ sendInvoice(order), updateInventory(order), notifyWarehouse(order), ]);
Why it fails: Promise.all rejects as soon as one member rejects. The other operations may still be running. If notifyWarehouse fails, the caller never sees the results of sendInvoice and updateInventory— even if they succeeded. This can leave workflows half-finished.
Good
const results = await Promise.allSettled([
sendInvoice(order),
updateInventory(order),
notifyWarehouse(order),
]);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
logger.warn("Partial failure in order processing", { failures });
}Why the fix works: Promise.allSettled always resolves with the status of every operation. You can inspect each result individually, handle partial failures, and decide what to retry. Use Promise.all when all operations must succeed for the workflow to be valid. Use Promise.allSettled when partial success is meaningful.
Mistake 4: Async callbacks where the return value is ignored
Bad
button.addEventListener("click", async () => {
await saveFormData();
});Why it fails: addEventListener does not consume the returned Promise. If saveFormData rejects, the rejection is unhandled because the event system discards the return value.
Good
button.addEventListener("click", () => {
saveFormData().catch((error) => {
showErrorToast(error.message);
logger.error("Form save failed", error);
});
});Why the fix works: the error is handled inside the callback with an explicit .catch(). The event handler does not return a Promise, so there is nothing for the event system to discard.
Why TypeScript does not save you here
TypeScript is a static type checker. It verifies that values match their declared types. A function that returns Promise<void> can be called without await and still compile without error, because the type is correct even though the runtime behaviour is unsafe.
TypeScript does not track whether a Promise rejection will be observed. It does not enforce that every .then() has a corresponding .catch(). It does not warn when you return a Promise from a try block without awaiting it.
This is exactly why lint rules matter for async correctness. The type system handles one layer of safety. Linting handles the next.
The safety net: linting and compiler-adjacent rules
Three ESLint rules from @typescript-eslint catch the most dangerous async mistakes:
| Rule | What it catches | Why it matters |
|---|---|---|
| no-floating-promises | Promise expressions that are not awaited, returned, or voided | Prevents fire-and-forget calls where rejections escape |
| no-misused-promises | Async functions passed where a void return is expected (e.g. event handlers, array callbacks) | Prevents rejections in callbacks where the Promise is silently discarded |
| return-await | Missing await on return statements inside try/catch | Ensures rejections are caught by the surrounding catch clause |
Minimal ESLint configuration to enable all three:
// eslint.config.js (flat config)
import tseslint from "typescript-eslint";
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
{
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/return-await": ["error", "in-try-catch"],
},
}
);Enable these in CI, not just locally
Safe async patterns
- Await promisesunless there is a deliberate reason not to. The default should be to observe every Promise's outcome.
- If intentionally detached, mark intent with
voidand attach error handling.void doWork().catch(logger.error)is explicit about both the intent and the failure path. - Use
Promise.allSettledfor partial-failure workflows. When not all operations need to succeed for the workflow to continue, inspect individual results instead of failing on the first rejection. - Prefer explicit variable naming around async boundaries. Assigning a Promise to a named variable makes it easier to spot when it is never awaited.
- Keep async operations inside clear request or component lifecycles. An async call that outlives its intended scope is a floating promise waiting to happen.
Global fallbacks: last line of defense, not primary strategy
Node.js
process.on("unhandledRejection", (reason, promise) => {
logger.error("Unhandled rejection", { reason, promise });
metrics.increment("unhandled_rejection");
// Coordinate graceful shutdown if needed
});Use this for logging, metrics, alerting, and safe shutdown coordination. Do not use it to silently swallow errors and continue normal operation. After an unhandled rejection, the process state may be compromised.
Browser
window.addEventListener("unhandledrejection", (event) => {
telemetry.report("unhandled_rejection", {
reason: event.reason,
});
});Modern browsers fire an unhandledrejection event on the window object. Use it for telemetry and last-resort error reporting. There is also a rejectionhandled event that fires when a previously unhandled rejection is caught later. Neither is a substitute for explicit local error handling.
Automate the catch
Code review catches some floating promises. Lint rules catch more. But async risk patterns are easy to miss in large pull requests, especially when the async boundary spans multiple files or layers of abstraction.
Run your code through CodeAva Code Audit to catch async risk patterns, bypassed try/catch blocks, and missing Promise handling before they ship. Automated analysis scales where manual review does not.
Practical debugging checklist
Did this async call get awaited?
If not awaited, was it intentionally detached with void and .catch()?
Is try/catch wrapping the actual awaited operation, not just the return statement?
Could Promise.all be failing fast and hiding partial work?
Is there a global unhandled rejection log for visibility?
Are lint rules (no-floating-promises, no-misused-promises) enabled in CI, not just locally?
Is the error happening in the browser, Node.js, or both?
Conclusion
If a Promise can reject, its failure path must be intentional. A green TypeScript build does not guarantee safe async control flow. The compiler checks types. It does not check whether you observed the outcome of every async operation.
The fastest way to stop these bugs is a combination of:
- Lint rules that catch floating promises and misused async callbacks in CI.
- Explicit async patterns where every Promise is either awaited, returned, or intentionally detached with error handling.
- Automated audit tooling that surfaces async risk patterns at scale.
Use CodeAva Code Audit to catch floating promises before they merge. Then read Beyond TSC: Advanced Static Analysis for TypeScript for the next layer of TypeScript correctness beyond what the compiler alone can enforce.




