Node.js & JavaScript Error Handling Best Practices
Key takeaways
How Node.js error handling actually works in production: the operational-vs-programmer-error distinction that decides whether you recover or crash, why throwing, rejecting, callbacks, and EventEmitter errors propagate differently, wrapping errors with cause, the fire-and-forget gap, why process-level handlers are a last resort, and how to centralize handling in Express.
Introduction
Error handling in Node.js has higher stakes than in a browser tab: an uncaught failure in a browser breaks one page; an uncaught failure in a Node.js server can take down every in-flight request the process was serving.
This article assumes you already know the language-level mechanics — try/catch/finally (including the “finally overrides return” gotcha), the built-in Error types and properties, subclassing Error, Promise .catch() chains, and try/catch around await. Those are covered with examples in JavaScript Error Handling | try/catch, the Error Object. What follows is specific to Node.js: how the runtime’s single-threaded, event-driven process model changes what “handling” an error means, why the same catch block behaves differently depending on how the failure was delivered, and what to do when nothing catches it at all.
The single most expensive mistake teams make is treating every catch block the same way — logging and moving on, regardless of what actually broke. That habit works fine until it silently keeps a corrupted process alive for hours, or until an operational hiccup that should have triggered a retry instead brings the whole service down. The distinction below is the first thing to get right, because it determines everything else.
Operational errors vs. programmer errors
Node.js’s own guidance has long drawn a hard line between two categories of failure, and conflating them is the root cause of most bad error-handling code.
Operational errors are failures a correctly written program is expected to encounter: a database connection that times out, a remote API that returns a 503, a file that does not exist, a client that sends malformed JSON, a socket that resets. Nothing is wrong with your code — the outside world did not cooperate. These are exactly the failures you should catch, log with context, and recover from: retry, return a 4xx/5xx to the caller, fall back to a cached value, or fail that one operation while the process keeps serving everyone else.
Programmer errors are bugs: calling .toUpperCase() on undefined, passing the wrong arguments, a typo in a property name that produces NaN three calls later. When one fires, the program’s actual state has diverged from the state your code assumes, and any code that runs afterwards is operating on an assumption that is already false. Catching a TypeError from such a bug and simply logging it lets the process limp forward with corrupted in-memory state, which routinely produces something worse than a crash: wrong data served to a user, a half-written transaction, a poisoned cache.
function isOperational(error) {
return error instanceof AppError // our own expected failures
|| ["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND"].includes(error.code);
}
function handleFailure(error) {
if (isOperational(error)) {
// Expected failure mode: log with context and let the caller
// (or a retry loop) decide what to do next. The process stays up.
logger.warn({ err: error }, "operational error");
return;
}
// Unexpected: internal state can no longer be trusted.
// Log everything, then let a supervisor restart the process.
logger.fatal({ err: error }, "programmer error — exiting");
process.exitCode = 1;
shutdown(); // see section 5
}
This is why “crash and restart” is not a lazy fallback for Node.js services — it is the correct response to a programmer error. A fresh process with a clean heap is safer than a long-lived one patched over with a catch block that swallowed a bug. The practical rule: define a small set of known, expected error types and treat everything outside that set as fatal by default, rather than trying to enumerate every possible crash.
Four ways an error can reach you
Node.js code fails through four delivery mechanisms, and each one needs a different piece of code to observe it. Writing a try/catch around code that actually fails through a different mechanism is the most common Node.js bug pattern.
flowchart TD
A[Error occurs] -->|Sync code: throw| B[Same-stack try/catch]
A -->|Promise rejects| C[".catch() or awaited try/catch"]
A -->|Node-style callback| D["callback(err, data)"]
A -->|EventEmitter / stream| I["emitter.on('error')"]
B -->|No matching catch| E["process 'uncaughtException'"]
C -->|No .catch / no await| F["process 'unhandledRejection'"]
D -->|err argument ignored| G[Silently swallowed — a bug]
I -->|No 'error' listener| E
E --> H[Log everything, then exit]
F --> H
A synchronous throw unwinds the current call stack and looks for the nearest try/catch on that same stack.
A rejected Promise does not unwind any stack. By the time a rejection is observed, the call stack that started the operation is gone; the rejection travels through the microtask queue to whichever .catch() or awaited try/catch is attached to that promise. A try/catch around code that merely calls an async function without awaiting it never sees the rejection (section 4).
A Node-style (error-first) callback is a plain calling convention: callback(err, data), where err is null on success. Nothing is thrown, so forgetting the if (err) check produces no visible error at all — data is just undefined and the bug surfaces somewhere else.
fs.readFile("/etc/app/config.json", "utf8", (err, data) => {
if (err) {
logger.error({ err }, "config read failed");
return;
}
start(JSON.parse(data));
});
Prefer the promise versions (fs/promises, util.promisify) in new code so that failures flow into the same await/try structure as everything else.
An EventEmitter 'error' event is the fourth path, used by streams, sockets, HTTP requests, and child processes. The rule is special: if an emitter emits 'error' and no 'error' listener is registered, Node.js throws the error, which usually becomes an uncaughtException and kills the process. A single unhandled socket error from one misbehaving client can therefore crash a server that otherwise handles errors carefully.
const { pipeline } = require("node:stream/promises");
// ❌ Each stream needs its own 'error' listener; pipe() does not forward errors
fs.createReadStream(src).pipe(zlib.createGzip()).pipe(fs.createWriteStream(dst));
// ✅ pipeline() attaches error handling to every stream, destroys all of them
// on failure, and gives you one promise to await
await pipeline(fs.createReadStream(src), zlib.createGzip(), fs.createWriteStream(dst));
The pipe() version is a classic leak: if the source fails, the destination is never closed and its file descriptor stays open. stream.pipeline (or stream/promises) is the fix. For one-off waits, events.once(emitter, "ready") returns a promise that rejects if 'error' fires first.
Wrapping errors without losing the cause
Subclassing Error (covered in the JavaScript article) gives you types to branch on. In a server you want two more things: an application-level meaning (status code, machine-readable code) and the original low-level error, attached rather than discarded.
class AppError extends Error {
constructor(message, { status = 500, code = "INTERNAL", cause, expose = false } = {}) {
// ES2022: the second argument attaches the original failure as error.cause
super(message, { cause });
this.name = new.target.name;
this.status = status;
this.code = code; // stable string for clients and dashboards
this.expose = expose; // safe to show message to the client?
// V8: drop this constructor's frame so the trace starts at the throw site
Error.captureStackTrace?.(this, new.target);
}
}
class NotFoundError extends AppError {
constructor(what, opts) { super(`${what} not found`, { status: 404, code: "NOT_FOUND", expose: true, ...opts }); }
}
async function getUser(id) {
try {
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) throw new NotFoundError("user");
return row;
} catch (err) {
if (err instanceof AppError) throw err;
// Driver-level failure: wrap with context, keep the original as cause
throw new AppError("failed to load user", { code: "DB_ERROR", cause: err });
}
}
Two mistakes are common here. The first is re-throwing a new error with only a string message and no cause, which discards the original stack and error code — by the time it reaches your logs, whether the root cause was a DNS failure, an auth failure, or a query syntax error is gone. Node.js prints the cause chain when an error is logged with console.error or util.inspect, and structured loggers like pino serialize it too, so attaching it is nearly free.
The second is branching only on instanceof. If AppError is instantiated by a different copy of your package (monorepos, bundlers, npm link), instanceof returns false even though the error looks identical. The string code property survives those boundaries, which is also why Node’s own errors carry codes like ERR_INVALID_ARG_TYPE and ENOENT. Branch on code when in doubt.
The fire-and-forget gap
try/catch around await works because await turns a rejection back into something that behaves like a throw at that line. That only applies to expressions you actually await:
function handleRequest(req, res) {
try {
// BUG: processAsync() returns a Promise that is never awaited
// and never given a .catch(). If it rejects, it does so after
// this try/catch has already finished.
processAsync(req.body);
res.status(202).send("accepted");
} catch (error) {
res.status(500).send("error"); // unreachable for failures inside processAsync()
}
}
Calling an async function without awaiting or .catch()-ing it hands back a Promise nobody is watching. The rejection does not vanish — it becomes an unhandled rejection (section 5), which on modern Node.js crashes the process. The fix is one of three explicit choices: await it inside the try; attach .catch(err => ...) directly to the call; or, if you genuinely want to respond before the background work finishes, hand it to something that owns its failures (a job queue, or a small wrapper that logs and records the failure).
function runInBackground(name, promise) {
promise.catch((err) => logger.error({ err, task: name }, "background task failed"));
}
runInBackground("persist-body", processAsync(req.body));
Two related traps are worth knowing. Promise.all rejects on the first failure but the other promises keep running, and their later rejections are handled (silently) by Promise.all; use Promise.allSettled when you need every outcome. And return promise versus return await promise inside a try block differ: only return await lets the local catch see the rejection.
Linters catch most of these statically: TypeScript-ESLint’s no-floating-promises flags any promise that is neither awaited, returned, nor .catch()-ed, and it is one of the highest-value rules to enable in a Node.js codebase.
Unhandled rejections and process-level handlers
Historically, a rejected Promise with no .catch() only printed a warning to stderr and the process kept running. A background job would fail, nothing would page anyone, and the only symptom was missing data noticed days later.
Node.js 15 changed the default --unhandled-rejections mode from warn to throw: an unhandled rejection is now raised as an uncaught exception and crashes the process unless you register an unhandledRejection handler. The change trades silent data loss for a loud crash that monitoring can alert on. If you upgrade an older service across that version, fire-and-forget calls that were “always broken” start crashing it — audit for them first.
process.on("unhandledRejection", (reason) => {
// reason is usually an Error, but Promise.reject("a string") is legal too
logger.fatal({ err: reason }, "unhandled rejection");
shutdown(1);
});
process.on("uncaughtException", (error, origin) => {
// Node's docs: resuming after this event is not safe — state may be corrupted
logger.fatal({ err: error, origin }, "uncaught exception");
shutdown(1);
});
Treat both as a safety net for observability, not a recovery mechanism. Their job is to make sure the failure is logged before the process disappears and to exit with a non-zero code so a supervisor (systemd, PM2, Kubernetes) restarts the service cleanly. Writing logic in these handlers that tries to “fix” the situation and keep going is exactly what the operational-vs-programmer distinction warns against.
A slightly better shutdown stops accepting new connections and lets in-flight requests finish, with a hard deadline:
let shuttingDown = false;
function shutdown(code = 1) {
if (shuttingDown) return;
shuttingDown = true;
process.exitCode = code;
server.close(() => process.exit()); // stop accepting, wait for in-flight
server.closeIdleConnections?.(); // Node 18.2+: drop idle keep-alive sockets
setTimeout(() => process.exit(), 10_000).unref(); // force exit if draining hangs
}
process.on("SIGTERM", () => shutdown(0)); // orchestrator asked us to stop
Two details matter in containers. Logs written just before process.exit() can be lost if the logger buffers asynchronously; flush it first (pino’s pino.final or a synchronous destination for fatal logs). And Kubernetes sends SIGTERM before killing a pod, so handling it with the same drain logic turns deployments from “some requests fail” into clean rollovers.
Centralizing handling in Express
In an HTTP service, most operational errors should become a response, and the translation should live in one place rather than in every route.
// Express 4 needs this wrapper; Express 5 forwards async rejections to next() itself
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/users/:id", asyncHandler(async (req, res) => {
res.json(await getUser(req.params.id)); // NotFoundError → 404 via the middleware below
}));
// Error middleware: four arguments, registered after all routes
app.use((err, req, res, next) => {
if (res.headersSent) return next(err); // let Express close the connection
const operational = err instanceof AppError && err.status < 500;
req.log?.[operational ? "warn" : "error"]({ err }, "request failed");
res.status(err.status ?? 500).json({
code: err.code ?? "INTERNAL",
message: err.expose ? err.message : "Internal Server Error", // never leak internals
requestId: req.id,
});
});
A few decisions are baked into this handler. Client-caused errors (4xx) are logged at warn so they do not drown real incidents in alerts. Messages are exposed only when the error explicitly says so, because stack traces, SQL, and file paths in responses help attackers. And a request ID ties the client’s report to the server log line.
Deciding whether a 500 inside a request should also restart the process is a judgment call. A TypeError in one route handler usually does not corrupt state shared with other requests, so many teams return 500 and alert rather than crash. Errors that can leave shared state inconsistent — a failure halfway through updating an in-memory cache, a broken connection pool — are the ones to escalate to shutdown().
Retrying operational failures
Retries belong squarely in operational-error territory. The retry loop shown in the JavaScript article works, but production retries need three more properties: they retry only errors that can succeed on retry, they use exponential backoff with jitter so many clients do not retry in lockstep, and they respect an overall deadline.
const RETRYABLE = new Set(["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EAI_AGAIN"]);
function isRetryable(err) {
return RETRYABLE.has(err.code) || [502, 503, 504].includes(err.status);
}
async function withRetry(fn, { attempts = 4, baseMs = 200, maxMs = 5_000, signal } = {}) {
for (let i = 1; ; i++) {
try {
return await fn({ signal });
} catch (err) {
if (i >= attempts || !isRetryable(err) || signal?.aborted) throw err;
const backoff = Math.min(maxMs, baseMs * 2 ** (i - 1));
const delay = Math.random() * backoff; // "full jitter"
await new Promise((r) => setTimeout(r, delay));
}
}
}
// Overall budget: give up after 10 s no matter how many attempts remain
const data = await withRetry(
({ signal }) => fetch(url, { signal }).then(checkStatus),
{ signal: AbortSignal.timeout(10_000) },
);
Never retry a programmer error — retrying a TypeError three times just delays the crash. Be careful with non-idempotent operations too: retrying a POST that timed out may create the order twice, because the timeout tells you nothing about whether the server processed it. Use an idempotency key the server can deduplicate, or retry only safe methods.
Logging errors so they are useful
A pile of ad-hoc console.error("something broke:", e) calls is nearly useless once you have thousands of log lines a minute. Log errors as structured objects:
const pino = require("pino");
const logger = pino({ serializers: { err: pino.stdSerializers.err } });
logger.error({ err, userId, route: req.route?.path }, "failed to load profile");
// → {"level":50,"err":{"type":"AppError","message":"failed to load user","stack":"...",
// "code":"DB_ERROR","cause":{...}},"userId":42,"route":"/users/:id","msg":"failed to load profile"}
The error serializer keeps type, message, stack, custom properties like code, and the cause chain. A consistent shape is what lets a log aggregator group, count, and alert on err.code instead of matching raw text, and it is what makes the cause you attached in section 3 actually visible when you need it. Avoid logging whole request bodies or headers alongside errors; they routinely contain tokens and personal data.
Related posts
- JavaScript Error Handling | try/catch, the Error Object
- Debugging Guide: Common Errors Across Languages
- Node.js Series #2: Modules (CommonJS & ESM)
Frequently Asked Questions (FAQ)
Q. Why doesn’t my try/catch catch an error thrown inside a setTimeout or event callback?
A. try/catch only covers code that runs synchronously inside the try block. A callback passed to setTimeout, an event emitter, or a stream runs later on a fresh call stack, after the try has already finished, so an error thrown there escapes to uncaughtException. Handle the error inside the callback itself, listen for the 'error' event on emitters and streams, or convert the API to a Promise and await it, which brings the error back into your try/catch.