Concept
The beginner framing: things go wrong in every real application, a database call times out, a user sends malformed input, a bug causes an unexpected undefined. How Node.js routes each of these failures to somewhere your code can actually respond to them is not one uniform mechanism, it depends heavily on how the error occurred.
The precise mental model: a synchronous throw and an asynchronous rejection propagate through completely different paths, even when they happen at the same point in logically similar code.
// SYNCHRONOUS throw, propagates up the call stack normally
function readConfig() {
throw new Error("config missing");
}
try {
readConfig();
} catch (err) {
console.log("caught:", err.message); // works, normal try/catch
}// ASYNCHRONOUS rejection, does NOT propagate up the call stack
async function readConfig() {
throw new Error("config missing"); // becomes a REJECTED promise, not a throw
}
readConfig(); // no try/catch here, the rejection has NOWHERE to go
// → becomes an 'unhandledRejection' event on `process`, NOT a normal throwuncaughtException vs. unhandledRejection
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
process.exit(1); // log and exit, do NOT try to keep running
});
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exit(1); // same rationale applies here too
});uncaughtException fires when a synchronous throw escapes every call frame with no try/catch to stop it, effectively Node's last-resort safety net before a crash. unhandledRejection fires when a promise rejects and nothing ever attaches a .catch()/await-with-try to it. Why "log and exit" is the correct handler for both, not "log and continue": once execution has reached this point, the process's internal state is of unknown integrity, some operation partway through mutating shared state may have been abandoned mid-way. Continuing to serve new requests on a process in an unknown state risks silent data corruption or cascading failures; a clean, logged exit (ideally under a process manager that restarts the process fresh) is safer than pretending nothing happened.
Error-first callbacks: the legacy convention
fs.readFile("file.txt", (err, data) => {
if (err) { // ALWAYS check err first, the convention's whole point
console.error(err);
return;
}
console.log(data);
});Before promises/async-await were idiomatic, Node's convention was the error-first callback: the first parameter is always either an Error or null, checked before touching any subsequent parameters. It's legacy now, most modern APIs (including fs.promises) prefer promises, but it still appears throughout older code and some core APIs, and the underlying discipline (always check for an error before proceeding) carries over regardless of which mechanism delivers it.
Operational vs. programmer errors
| Operational errors | Programmer errors | |
|---|---|---|
| Examples | Network timeout, invalid user input, file not found, a downstream service returning an error | undefined is not a function, a typo'd variable name, calling an API incorrectly |
| Nature | Expected to happen sometimes, even in correct code | A genuine bug, the code itself is wrong |
| Correct response | Handle it: retry, return a clean error response, log and continue | Do NOT try to keep running, the process's assumptions about its own state are now suspect; crash and let a process manager restart cleanly |
This distinction, common in Node.js production guidance, is what separates a try/catch around a database call (operational, you expect it might fail sometimes) from an uncaughtException handler (programmer error territory, something the code itself got wrong, where "log and exit" is the only safe response).
AsyncLocalStorage: context without parameter drilling
const { AsyncLocalStorage } = require("async_hooks");
const requestContext = new AsyncLocalStorage();
function handler(req, res) {
const requestId = crypto.randomUUID();
requestContext.run({ requestId }, () => {
processRequest(req, res); // requestId available WITHOUT being passed as a parameter
});
}
function processRequest(req, res) {
logWithContext("handling request"); // deep in the call chain, no requestId parameter needed
}
function logWithContext(
Confirmed stable and working across an async boundary directly against this app's installed Node v23.11.0: AsyncLocalStorage.run() establishes a context that remains accessible via .getStore() from anywhere in the async call chain spawned inside that run() callback, including after awaits, setTimeout, and other async boundaries, without threading a requestId parameter through every intermediate function signature.
http.createServer((req, res) => {throw new Error('boom'); // SYNCHRONOUS throw});
A synchronous throw inside the request handler is caught by Node's internal server machinery, this specific request's connection typically errors out, but the throw does NOT escape to become an uncaughtException. The server keeps running.
Try It
Predict the outcome before checking the solution.
process.on("unhandledRejection", () => console.log("caught by process handler"));
const server = http.createServer(async (req, res) => {
await db.query("SELECT ..."); // rejects, no try/catch anywhere in this handler
res.end("ok");
});A request comes in and the query rejects. Does the client ever get a response? Does the server crash?
Solution
The client's connection typically hangs or eventually times out, res.end() is never reached, since the await threw and nothing in the handler caught it. The process-level unhandledRejection handler DOES fire (logging "caught by process handler"), but that handler has no reference to res for THIS specific request, it can't send a response to fix the hanging client. The server itself doesn't crash (since a handler is registered for unhandledRejection), but this particular request is left in a broken state. The actual fix is a try/catch inside the handler itself, which has access to res and can send a proper error response.
Implement It Yourself
Build a minimal async-handler wrapper that turns any rejection into a proper error response, instead of an escaping unhandledRejection:
function wrapAsync(handler) {
return async (req, res) => {
try {
await handler(req, res);
} catch (err) {
console.error(err);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
};
}
const server = http.createServer(
This is the exact mechanism most frameworks' "async route handler" support provides internally, wrapping every handler in a try/catch so a rejection becomes a normal, request-scoped error response instead of an unreachable process-level event.
Under the Hood
This directly extends the 'error'-event special-casing from EventEmitter & Async Patterns, that topic's narrow case (an EventEmitter crashing the process when 'error' has no listener) is one specific instance of the broader principle here: Node surfaces failures differently depending on the mechanism, and each mechanism needs its own explicit handling. AsyncLocalStorage-based request IDs are the mechanism Observability & Monitoring builds on for correlating logs across a single request's full async lifecycle.
Common Mistakes
1. Treating unhandledRejection as a substitute for per-request error handling
process.on("unhandledRejection", () => { /* log it */ }); // NOT a fix for missing try/catchA process-level handler can log the failure, but it has no access to the specific res object for the request that failed, it cannot send that client a proper response. Per-handler try/catch is still required for correct request-level behavior.
2. Continuing to run after uncaughtException without exiting
process.on("uncaughtException", (err) => {
console.error(err); // ❌ logs but doesn't exit, process keeps running in an unknown state
});Continuing to serve requests after an uncaught exception risks operating on corrupted in-memory state, the safe response is to log and exit, letting a process manager restart cleanly.
3. Passing request context through many function parameters instead of AsyncLocalStorage
function a(requestId) { b(requestId); }
function b(requestId) { c(requestId); }
function c(requestId) { console.log(requestId); } // ❌ threaded through every layer manuallyFor context that needs to reach deeply nested calls (logging, tracing), AsyncLocalStorage avoids this parameter-drilling entirely, while still correctly scoping the value to the specific async chain it belongs to.
Best Practices
- Wrap every async request handler in a try/catch (or a framework/utility that does this automatically), never rely on a process-level
unhandledRejectionhandler as the actual fix for a specific request's error. - Distinguish operational from programmer errors explicitly in how you handle them: retry/return-clean-error for the former, log-and-exit for the latter.
- Always register both
uncaughtExceptionandunhandledRejectionhandlers in production, even if their only job is to log and exit cleanly, an unregistereduncaughtExceptioncrashes the process anyway, but with a less controlled/loggable exit. - Use
AsyncLocalStoragefor cross-cutting request context (request IDs, user IDs for logging) instead of parameter drilling.
Performance Tips
AsyncLocalStoragehas a real, if generally small, performance cost (tracking context across async boundaries isn't free), for extremely hot code paths, benchmark before assuming it's negligible, though for typical request-scoped logging/tracing use it's the standard, accepted tradeoff.- A missing per-handler
try/catchdoesn't just risk an ugly error, it can leave sockets open and unresponded-to, which under load accumulates as resource exhaustion (hung connections) rather than a clean, fast failure.
