Error handling in Node is a minefield because there are three different error-delivery mechanisms: thrown exceptions, callback’s err first argument, and rejected Promises. Mix them up and errors silently disappear.
The async/await rule
With async/await, errors propagate via thrown exceptions — same as sync code. try/catch catches them.
async function getUser(id) {
try {
const user = await db.findUser(id);
return user;
} catch (err) {
logger.error({ err, id }, 'failed to load user');
throw err; // re-throw, let caller decide
}
}
Key word: re-throw. Catching to log and then returning undefined (silently swallowing) is how bugs hide for months. Either re-throw, or return a sentinel and document it loudly.
Promises without await
If we fire a promise and don’t await it (or .catch it), a rejection becomes an unhandled promise rejection. Bad.
// BAD
async function handler(req, res) {
doBackgroundWork(); // returns a promise, we ignored it
res.json({ ok: true });
}
If doBackgroundWork throws, the error vanishes. Either await it, or chain a .catch:
doBackgroundWork().catch((err) => logger.error({ err }, 'bg work failed'));
The two process-level safety nets
Node fires these events for errors we missed:
process.on('uncaughtException', (err, origin) => {
logger.fatal({ err, origin }, 'uncaught exception');
// do minimal sync cleanup, then EXIT
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error({ reason }, 'unhandled rejection');
// In modern Node, these are fatal by default. Crash.
throw reason;
});
In simple language: uncaughtException is “a thrown error nobody caught.” unhandledRejection is “a rejected promise nobody .catched.” Both mean we have a bug somewhere.
To crash or not to crash?
This is the interview question. The answer: on uncaughtException, always crash.
Why? After an uncaught exception, our process is in an undefined state. Half-completed transactions. Half-closed file descriptors. Variables in inconsistent state. Continuing to serve traffic could corrupt data.
The correct flow:
→ catch, log, return error response. Keep running.
→ crash. Process manager restarts us. Fix the bug.
→ already crashing. Make sure restart is configured.
Operational errors = expected, recoverable. Programmer errors = bugs, unrecoverable mid-flight. Joyent’s classic article codified this distinction; it’s still the right model.
Express/Koa pattern
In Express 4, async route handlers don’t auto-forward rejected promises. Wrap them.
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError('user');
res.json(user);
}));
// Central error middleware
app.use((err, req, res, next) => {
logger.error({ err, url: req.url }, 'request failed');
const status = err.status || 500;
res.status(status).json({ error: err.message });
});
Express 5 (now stable) auto-forwards async errors. One less footgun.
Custom error classes
Use error subclasses to tell apart “expected” errors from genuine bugs.
class AppError extends Error {
constructor(message, status = 500) {
super(message);
this.name = this.constructor.name;
this.status = status;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404);
}
}
// In the error middleware
if (!err.isOperational) {
logger.fatal({ err }, 'non-operational error — restarting');
process.exit(1);
}
Streams and EventEmitters
Streams emit 'error'. If nobody listens, Node crashes the process. Always attach:
fs.createReadStream('big.csv')
.on('error', (err) => logger.error({ err }, 'read failed'))
.pipe(transform)
.on('error', (err) => logger.error({ err }, 'transform failed'));
Or better — use stream.pipeline which propagates errors cleanly:
import { pipeline } from 'node:stream/promises';
try {
await pipeline(fs.createReadStream('in.csv'), transform, fs.createWriteStream('out.csv'));
} catch (err) {
logger.error({ err }, 'pipeline failed');
}
Checklist
- Wrap every async route handler so rejections reach error middleware.
- Have a central error logger — never
console.errorand move on. - Subscribe to
uncaughtExceptionandunhandledRejection, log, then exit. - Run under a process manager (PM2, systemd, Docker restart policy) so crash → restart is fast.
- Distinguish operational from programmer errors — recover from the first, crash on the second.