Diagnosing Unhandled Promise Rejections and Silent Async Failures in JavaScript
A diagnostic guide to unhandled promise rejections and silent async errors in Node.js and browsers, with ordered checks, cause-specific fixes, and escalation criteria.
19 Nov 2025, 00:55 UTC

A request hangs, the UI never updates, and the only signal is a timeout or a blank screen. In Node.js, the process may exit with a nonzero code and no stack trace near the failure. In the browser, the console shows Uncaught (in promise) but the page keeps running. These are symptoms of unhandled promise rejections: async operations that failed without a terminal error handler. The useful takeaway is that the fix depends on where the promise boundary lost its handler, not on adding another global catch-all.
Promises settle inside the microtask queue, which runs after the current synchronous code finishes. If no .catch, await, or try/catch is attached by the time the rejection reaches that queue, the runtime emits an unhandled rejection event. Common escape points are async callbacks passed to forEach, event listeners, timers, or fire-and-forget background jobs.
Cause and diagnostic signals
| Cause | Diagnostic signal |
|---|---|
| Promise created but not awaited or returned | Creation stack shows an async call with no await, return, or .catch in the caller |
Async callback throws outside try/catch | Rejection appears after the request lifecycle has ended; correlation id points to a background task |
.catch block swallows the error | No unhandledRejection event fires, but downstream state is stale or a retry never happens |
| Fire-and-forget work lacks a terminal handler | Rejection correlates with timers, I/O, or background jobs rather than a user action |
Rejection reason is not an Error | reason.stack and reason.message are undefined; util.inspect reveals a string, plain object, or undefined |
Ordered checks
Check 1: Reproduce under observability
In Node.js, run the entry point with warn-only rejection mode from your terminal. No elevated permissions are required.
node --unhandled-rejections=warn app.js
Replace app.js with your actual entry file. Expected check: a warning is printed with the rejection reason and stack. Risk: this mode differs from the Node 15+ production default, so do not rely on it for final validation.
In a browser, open DevTools (F12 or Cmd+Opt+I), select the Sources tab, and enable “Pause on exceptions.” Expected check: the debugger pauses at the throw site when the rejection occurs. Risk: it may pause inside third-party libraries, not only your application code.
Check 2: Capture the reason and creation stack
Add a temporary handler at the very top of your Node.js entry file, before other imports:
process.on('unhandledRejection', (reason, promise) => {
const timestamp = new Date().toISOString();
const correlationId = globalThis.correlationId || 'unknown';
console.error(`[${timestamp}] unhandledRejection correlationId=${correlationId}`);
console.error(reason instanceof Error ? reason.stack : reason);
});
Expected check: the handler fires for the reported symptom and logs the reason. Correlate the timestamp with deploy time, feature flags, payload shape, and load to determine whether the failure is deterministic or race-dependent. For the creation stack, enable async stack traces in Node or source maps in browser DevTools, then reproduce the failure. Risk: if this handler calls process.exit or only calls console.error, it can hide the original stack and turn recoverable errors into outages.
In the browser, add a temporary listener:
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection at', new Date().toISOString());
console.error('Reason:', event.reason);
console.error('Promise:', event.promise);
});
Expected check: the event fires with event.reason populated. Do not call event.preventDefault() unless you intend to suppress the default browser logging; it does not fix the rejection.
Check 3: Inspect non-Error rejection reasons
Rejection reasons are not guaranteed to be Error objects. In Node.js, inspect the value directly:
const util = require('util');
process.on('unhandledRejection', (reason) => {
console.error(util.inspect(reason, { depth: 4 }));
});
Expected check: you can see whether the reason is a string, plain object, null, or undefined. Risk: assuming reason.message exists will crash the handler itself.
Check 4: Audit async boundaries
Search for async functions passed to forEach, map, event listeners, timers, and queue processors. Expected check: every path either awaits the promise, returns it to a caller that handles it, or attaches a terminal .catch.
Fixes tied to findings
Missing await or return
If the creation stack shows a promise that was started but never chained, add await or return so the caller’s error handling applies.
// Before: rejection escapes the request handler
app.get('/data', (req, res) => {
fetchData(req.query.id);
res.status(202).send();
});
// After: the handler owns the rejection
app.get('/data', async (req, res, next) => {
try {
const data = await fetchData(req.query.id);
res.send(data);
} catch (err) {
next(err);
}
});
Async callback throws outside try/catch
If the rejection appears after the request lifecycle, wrap the async callback and log with context before rethrowing.
queue.process(async (job) => {
try {
await job.run();
} catch (err) {
logger.error({ jobId: job.id, err }, 'job failed');
throw err;
}
});
Batch operations and fire-and-forget work
When multiple async operations run in parallel and partial failure is acceptable, use Promise.allSettled instead of Promise.all. For fire-and-forget tasks, attach a terminal .catch or move the work into a managed queue.
const results = await Promise.allSettled(urls.map(fetch));
const failures = results.filter(r => r.status === 'rejected');
Swallowed errors and cancellation
If a .catch block only logs and does not rethrow or recover, the failure becomes silent. Rethrow after logging. For cancellation, use AbortController and treat AbortError as an expected control-flow signal, not a failure.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
await fetch(url, { signal: controller.signal });
} catch (err) {
if (err.name === 'AbortError') return;
throw err;
}
Version-sensitive runtime behavior
Node.js changed the default unhandled-rejection mode from warn to throw starting with Node 15. Under the throw default, an unhandled rejection exits the process with a nonzero code. Browsers generally emit an unhandledrejection event without stopping page execution. Confirm the actual runtime default before relying on process exit or page survival.
Escalation criteria
- Rejections are nondeterministic under load and the creation stack is insufficient to isolate the boundary.
- The reason is native/internal or security-sensitive, such as an engine-level
RangeErroror a payload containing credentials. - A fix would change public API contracts or require altering runtime flags in production.
- The same rejection appears across services after a shared dependency change.
Verifying the fix
In CI, add a test that awaits the same code path and fails if the unhandledrejection event fires. Run it with the same runtime flags used in production. After deploying, replay the original payload and monitor logs for a defined window, such as one full traffic cycle. Verify that no new UnhandledPromiseRejection or Uncaught (in promise) entries appear, and confirm that intended error handling—HTTP 500 responses, retry queues, or user-facing messages—still occurs.
Limitations: a global handler that only exits or logs can hide the original stack. Browser unhandled rejections do not necessarily stop execution, so absence of a crash is not evidence of safety. Rejection reasons may be non-Error values, so never assume reason.stack or reason.message exists.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.