A background worker sits quietly for weeks, chewing through a job queue without incident, and then one Tuesday afternoon it disappears mid-batch, taking forty in-flight jobs down with it. No stack trace pointing at the offending line, no alert until a customer notices their export never finished. Nine times out of ten, the culprit is one of the most under-discussed failure modes in server-side JavaScript: unhandled promise rejections. They are easy to introduce, easy to miss in code review, and in modern Node.js they no longer just print a warning — they can end your process outright. This post walks through what unhandled promise rejections actually are at the engine level, why they are far more dangerous in long-running Node.js services than in typical web requests, how Node's default behavior around them has shifted over the years, and the concrete patterns you can use to stop them from quietly killing your jobs.
What an Unhandled Promise Rejection Actually Is
At the JavaScript engine level, a promise is a state machine with three possible states: pending, fulfilled, or rejected. When an async operation fails — a network call times out, a database query throws, a JSON parse blows up — the promise representing that operation transitions to the rejected state and carries a reason, usually an Error object. That's all a rejection is: a value flowing down the promise's error channel instead of its success channel.
The engine only considers a rejection "handled" if, by the time it does its bookkeeping (which happens on a microtask checkpoint, not synchronously), there is a .catch() handler, a second argument to .then(), or a surrounding try/catch around an await somewhere in that promise's chain. If none of those exist anywhere in the chain, the rejection is classified as unhandled. This is the precise, narrow definition of unhandled promise rejections: a promise that reaches the rejected state with zero handlers attached anywhere along its chain of consumers.
It's worth being precise about "anywhere in its chain," because this is where a lot of confusion creeps in. If you call doSomething().then(handleSuccess) and never add a second .then() or a .catch(), the rejection is unhandled even though you technically "did something" with the promise. Attaching a success handler does not count as handling the failure path. Likewise, wrapping the call site in a try/catch only helps if you actually await the promise inside that try block. Calling an async function without awaiting it and without chaining a .catch() produces a promise that nobody is watching, and if it rejects, the engine has no handler to route the error to.
Why This Is Especially Dangerous in Long-Running Processes
In a typical HTTP request handler, an unhandled rejection is often survivable in the sense that the damage is scoped to a single request. The request might hang or return a 500, a load balancer eventually times it out, and the process itself usually continues serving other traffic — assuming the crash-on-unhandled-rejection behavior described below doesn't apply, or assuming the rejection happens inside a context that gets caught by a framework-level error boundary. It's not great, but the blast radius is one request.
Background workers, queue consumers, cron-driven batch jobs, and long-running WebSocket servers do not have that luxury. These processes are designed to stay alive indefinitely and handle many units of work — jobs, messages, connections — over their lifetime, often concurrently. If an unhandled promise rejection terminates the process, it doesn't just fail the one job whose code raised the rejection. It fails every job currently in flight in that process, drops anything queued in memory, and potentially leaves external resources (database transactions, file handles, distributed locks) in an inconsistent state because there was no chance to run cleanup logic. A crash that would be a minor blip in a stateless request handler becomes a cascading outage in a stateful worker fleet, especially if your process manager restarts workers faster than they can drain their queues, creating a crash loop.
How Node.js Default Behavior Has Changed Over Time
Node's handling of unhandled promise rejections has become progressively stricter, and understanding that history matters because plenty of production code and blog posts still assume the old, forgiving behavior.
In older versions of Node.js, an unhandled rejection printed a UnhandledPromiseRejectionWarning to stderr and the process kept running as if nothing had happened. That warning was easy to lose in noisy logs, especially in containerized environments where stdout and stderr are frequently interleaved or truncated. The practical effect was that a rejected promise with no handler would just vanish into the void — the operation silently failed, any awaiting code never resumed, and unless you were watching your logs closely, you'd never know.
Over subsequent Node.js releases, this behavior tightened. Node added the ability to opt into terminating the process on unhandled rejections via a command-line flag, and eventually flipped the default so that recent LTS versions of Node.js terminate the process by default when a promise rejection goes unhandled, unless you've registered your own process.on('unhandledRejection', ...) listener to intercept it. The exact major version where the default flipped has shifted across Node's release history and depends on which flag behavior you're comparing against, so rather than pin a specific number here, the safe operating assumption for any current LTS release is: treat an unhandled rejection as fatal by default. If you're not certain which behavior your runtime uses, the Node.js documentation on process events is the authoritative source, and it's worth checking against the exact Node version pinned in your Dockerfile or package.json engines field.
This shift is arguably a good thing from a "fail loudly" philosophy — silent data loss is worse than a visible crash — but it means that code written assuming the old warn-and-continue behavior is now a landmine. A fire-and-forget async call that was merely sloppy five years ago can now bring down an entire worker process today. This is precisely why understanding unhandled promise rejections isn't an academic exercise; it's a prerequisite for running reliable Node.js services in production.
How This Silently Kills Background Jobs
Picture a typical queue consumer: it pulls messages off a queue (SQS, RabbitMQ, BullMQ, whatever your stack uses), processes several jobs concurrently for throughput, and acknowledges each job as it completes. Somewhere inside the handling logic for one job type, there's an async call that isn't awaited and has no .catch() — maybe it's a "fire and forget" analytics ping, or a cache warm that "doesn't matter if it fails."
Under normal conditions that stray promise resolves fine and nobody notices the risk. Then one day the downstream service it calls has a blip, that promise rejects, there's no handler anywhere in its chain, and Node's default unhandledRejection behavior terminates the process. The problem is that this termination is not scoped to the job that triggered it. Every other job the worker was concurrently processing — jobs that had nothing to do with the analytics ping — gets killed mid-execution along with it. If those jobs weren't idempotent or hadn't checkpointed their progress, they're now in an undefined state: possibly partially applied, possibly requeued and about to be retried from scratch, possibly lost outright depending on your acknowledgment strategy.
The forensics afterward are usually miserable. The process log shows a stack trace, if you're lucky, but it often points into deep library internals (an HTTP client, a database driver) rather than the actual line of business logic that started the offending call chain, because the stack captured at rejection time reflects the async call stack at that moment, not a clean pointer back to "job #48213 in queue X." You end up correlating timestamps across your process logs, your queue's dead-letter records, and whatever partial output made it to your database, trying to reconstruct which of the twelve jobs in flight was the trigger and which were collateral damage. Multiply this by a worker fleet that auto-restarts on crash, and you can get a slow-burning incident where a fleet of workers crash-loops for hours, processing a handful of jobs before dying repeatedly, while overall throughput quietly collapses and nobody gets paged because each individual crash looks unremarkable in isolation.
Practical Prevention Patterns
The good news is that most of this is preventable with a small number of consistently applied habits. None of them are exotic — the difficulty is discipline, not technique.
Always Attach a Catch Handler to Fire-and-Forget Promises
If you intentionally don't want to await a promise — you want the caller to move on immediately — you still owe that promise a rejection handler. The broken pattern looks deceptively harmless:
// BROKEN: fire-and-forget with no error handling
function processOrder(order) {
// Kick off a non-critical audit log write, don't wait for it
writeAuditLog(order);
return finalizeOrder(order);
}
async function writeAuditLog(order) {
const client = getAuditServiceClient();
await client.send(order.id, order.total);
}
If client.send rejects for any reason — a timeout, a bad response, the audit service being down — that rejection has nowhere to go. Nobody awaited writeAuditLog(order), and nobody chained a .catch() onto it. In an older Node.js runtime this silently disappears with maybe a warning; in a modern LTS runtime this can crash the entire process, taking down whatever else that process was doing at the time, all because of a log write nobody even needed to be synchronous.
The fixed version explicitly acknowledges that the call is fire-and-forget and handles its failure path on purpose:
// FIXED: fire-and-forget with explicit error handling
function processOrder(order) {
writeAuditLog(order).catch(function (err) {
logger.error('audit log write failed', {
orderId: order.id,
error: err.message,
stack: err.stack
});
});
return finalizeOrder(order);
}
async function writeAuditLog(order) {
const client = getAuditServiceClient();
await client.send(order.id, order.total);
}
The change is one line, but it converts a process-ending landmine into a logged, contained failure. The order still finalizes normally, and you get a record of exactly which order's audit log failed and why.
Wrap Async Job Handlers in a Consistent Top-Level Try/Catch
Fire-and-forget calls are one source of unhandled rejections, but the more common source in worker code is simply forgetting that the top-level handler invoked by your queue library needs its own guard. If your job runner calls your handler and awaits it, an uncaught throw inside an async function turns into a rejected promise on that call — and if the queue library itself doesn't wrap that await in a try/catch (or if you're not passing it through the library correctly), you're back to an unhandled rejection.
The safest approach is to never let a bare async handler be the direct entry point. Wrap every job handler with a consistent boundary:
async function runJobSafely(jobHandler, job) {
try {
await jobHandler(job);
} catch (err) {
logger.error('job failed', {
jobId: job.id,
jobType: job.type,
error: err.message,
stack: err.stack
});
// Let the queue library decide retry/dead-letter behavior
throw err;
}
}
This does two things. It guarantees the failure is logged with context before anything else happens to it, and it re-throws so the queue library's own retry or dead-letter logic still runs as designed. The key property is consistency: every job, regardless of type, goes through the same wrapper, so there's no code path where a handler's rejection can slip through unguarded.
Worker Queue Example
Putting this together, a queue consumer that processes jobs concurrently should isolate each job's failure from its siblings. Here's a simplified but realistic shape:
const queue = require('./queue');
const logger = require('./logger');
async function handleJob(job) {
switch (job.type) {
case 'send-email':
return sendWelcomeEmail(job.payload);
case 'generate-report':
return generateReport(job.payload);
default:
throw new Error('unknown job type: ' + job.type);
}
}
queue.on('job', function (job) {
handleJob(job)
.then(function () {
return queue.acknowledge(job.id);
})
.catch(function (err) {
logger.error('job processing failed', {
jobId: job.id,
jobType: job.type,
error: err.message,
stack: err.stack
});
return queue.nack(job.id, { requeue: true });
});
});
Notice that handleJob(job) is never left dangling — its full chain, success or failure, ends in either acknowledge or nack, both wrapped so their own rejections are caught too, if you extend this pattern further. Because each job's promise chain is self-contained and fully handled, one job's failure cannot become an unhandled rejection that crashes the process and drags every concurrently running job down with it. This is the core defensive idea: contain each job's failure inside its own chain so it can never become an ownerless, unhandled rejection.
Silent Swallowing Is Just as Dangerous as Not Catching
It's tempting, once you've internalized "always add a .catch()," to sprinkle empty catch blocks everywhere and call it done. This is a trap, and arguably it produces a worse failure mode than an unhandled rejection, because at least an unhandled rejection is loud — it crashes something, prints something, triggers something. An empty catch block does none of that:
// This "fixes" the crash but destroys the signal
async function chargeCustomer(order) {
try {
await paymentGateway.charge(order.id, order.total);
} catch (err) {
// swallowed - do nothing
}
}
Technically, this satisfies the letter of "handle every rejection." The process won't crash. But now a failed payment charge disappears without a trace: the order proceeds as if it were paid, no log entry exists to explain why, and the first anyone hears about it is a finance reconciliation discrepancy weeks later with no breadcrumbs leading back to the cause. A crash is at least discoverable — an empty catch block is a permanent blind spot.
Meaningful error handling means every catch block does at least one of three things: logs enough structured context to diagnose the failure later, takes a corrective action (retry, fallback, compensating transaction), or re-throws so a higher-level handler can decide. Silence should never be the outcome of a catch block. A reasonable minimum bar looks like this:
async function chargeCustomer(order) {
try {
await paymentGateway.charge(order.id, order.total);
} catch (err) {
logger.error('payment charge failed', {
orderId: order.id,
customerId: order.customerId,
amount: order.total,
gatewayError: err.message,
stack: err.stack
});
throw new PaymentFailedError(order.id, err);
}
}
Structured logging matters here as much as the fact of logging. A log line that just says "payment failed" is barely more useful than nothing once you're sifting through thousands of log entries during an incident. Capturing the originating context — the job ID, the order ID, the customer ID, the specific operation, the underlying error message and stack — is what turns a log into something you can actually query and correlate. If your logging pipeline supports structured fields (most do — JSON logs shipped to something like an ELK stack, Datadog, or similar), attach that context as fields rather than interpolating everything into a free-text string. It's the difference between grepping through gigabytes of text at 2 a.m. and running a query for every failure tied to a specific job type in the last hour.
Monitoring and Alerting: Using unhandledRejection as a Last-Resort Catch-All
Everything above is about preventing unhandled rejections at the source. But defense in depth means also assuming, realistically, that you will miss one — a new contributor adds a fire-and-forget call without a catch, a third-party library changes its promise-returning behavior in a minor version bump, an edge case nobody tested triggers a rejection in code you thought was safe. This is what the process-level unhandledRejection event is for: not as your primary error-handling mechanism, but as a safety net that guarantees nothing falls through completely silently.
process.on('unhandledRejection', function (reason, promise) {
logger.error('unhandled promise rejection detected', {
reason: reason && reason.message ? reason.message : String(reason),
stack: reason && reason.stack ? reason.stack : 'no stack available'
});
// Notify your error tracking / alerting tool here, e.g.
// errorTracker.captureException(reason);
// Optional: exit deliberately after logging, so process managers
// restart cleanly instead of being killed uncontrolled by the runtime
process.exit(1);
});
process.on('uncaughtException', function (err) {
logger.error('uncaught exception', {
error: err.message,
stack: err.stack
});
process.exit(1);
});
A few things matter about how this handler is used. First, it should always forward the error to whatever error tracking and alerting tool your team already relies on — Sentry, Datadog, Rollbar, or an equivalent — so that an unhandled rejection becomes a page or a ticket, not a line buried in a log file nobody reads until after the postmortem. The whole point of instrumenting this handler is to convert a silent outage into a visible, actionable alert the moment it happens, rather than discovering it hours later through a downstream symptom like a growing queue backlog.
Second, resist the temptation to use this handler to keep the process alive indefinitely by simply logging and continuing. Once Node has told you a rejection was unhandled, you genuinely don't know what state your application is in — some cleanup may not have run, some resource may be half-initialized. The safer pattern in most production setups is to log with full context, flush that log and any pending telemetry, and then exit deliberately so your process manager (PM2, Kubernetes, systemd, whatever supervises your workers) can restart you into a known-good state, ideally after finishing or requeuing any currently in-flight jobs if your architecture allows for a graceful drain.
Third, treat every event that reaches this handler as a bug to fix, not a category of expected behavior. If you find yourself relying on it regularly, that's a signal to go back through the code paths flagged in your alerting tool and apply the patterns from earlier in this piece — explicit .catch() handlers, consistent top-level try/catch wrapping, and structured logging at the source — rather than treating the catch-all as a permanent substitute for fixing root causes. You can read Node's own documentation on this and related process events at the official Node.js process events reference, and the broader Node.js docs are a good starting point if you need to double check version-specific behavior for the runtime you're deploying.
None of this requires exotic tooling — it requires treating promise rejection paths with the same seriousness as synchronous error handling, and building the habit of asking "where does this go if it fails?" for every async call you write, especially the ones you don't plan to await. Getting this right across an entire codebase, particularly one with a sprawling set of background workers, queue consumers, and scheduled jobs, is exactly the kind of unglamorous reliability work that pays off the first time it silently prevents an outage instead of causing one.
I'm a full-stack developer specializing in Node.js, Laravel, and ASP.NET Core, and I've spent a good chunk of the last 6+ years debugging exactly this class of bug in production worker fleets. If your team is auditing a Node.js service for this failure mode, or you'd rather bring in someone who already has these patterns memorized, feel free to reach out — here's my Node.js developer profile if you want to see more of my background.
Top comments (0)