Answer: Each async style has its own error channel; mixing them incorrectly is where bugs hide.
Callbacks — error-first argument:
fs.readFile('f.txt', (err, data) => {
if (err) return handle(err);
use(data);
});
Promises — .catch:
doWork()
.then(use)
.catch(handle); // catches rejections from the whole chain
async/await — try/catch:
try {
const data = await readFile('f.txt');
use(data);
} catch (err) {
handle(err);
}
Critical subtleties:
- A
try/catcharoundawaitonly catches the awaited promise. If you forgetawait, the error escapes as an unhandled rejection:
try {
doAsync(); // ❌ no await — a rejection here is NOT caught
} catch (e) {}
- Errors thrown inside a callback passed to something else aren't caught by an enclosing try/catch:
try {
setTimeout(() => { throw new Error('boom'); }, 0); // ❌ escapes — crashes process
} catch (e) {}
- In Express, synchronous throws in a handler are caught by Express, but rejected promises are not (pre-Express 5) — you must call
next(err)or use an async wrapper.
Rule: match the handler to the async mechanism, and always await (or .catch) every promise.
Answer: These are last-resort process-level events, not a substitute for local error handling.
uncaughtException — a thrown error propagated to the top of the stack with no try/catch:
process.on('uncaughtException', (err) => {
logger.fatal(err);
// do minimal synchronous cleanup, then exit
process.exit(1);
});
unhandledRejection — a promise rejected with no .catch/try-catch:
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection', reason);
process.exit(1);
});
(In modern Node, an unhandled rejection terminates the process by default.)
Why you should exit, not continue:
- After an uncaught exception the application is in an undefined state — resources may be half-updated, locks held, connections dangling. Continuing risks corrupted data and memory leaks.
- Best practice (per Node docs): log, release critical resources, and let the process crash, then rely on a process manager (PM2, systemd, Kubernetes) to restart a clean instance.
What NOT to do:
// ❌ Anti-pattern: swallow and keep running
process.on('uncaughtException', () => { /* ignore */ });
This turns crashes into silent corruption.
The real fix: handle errors where they occur (try/catch, .catch, error middleware). These global handlers are a safety net for logging and orderly shutdown — not normal control flow.
Answer: This distinction (popularized by Joyent's Node error-handling guide) drives how you should respond to an error.
Operational errors — runtime conditions a correct program will encounter:
- Failed DNS lookup, connection reset, request timeout.
- Invalid user input, 404 from an upstream API, disk full, file not found.
- These are expected. Handle them: validate, retry with backoff, return a proper error response, use a fallback.
try {
const res = await fetch(url);
if (!res.ok) return reply(res.status, 'Upstream error');
} catch (err) {
// network timeout etc. — operational
return reply(503, 'Service unavailable');
}
Programmer errors — bugs in your code:
undefined is not a function, reading a property ofnull, passing the wrong type, forgetting toawait.- You cannot sensibly recover from these at runtime because the program's assumptions are broken. The right response is to let it crash, log with a stack trace, and fix the code.
Why it matters:
- Wrapping everything in try/catch and swallowing errors hides programmer bugs and leaves the process in a bad state.
- Retrying a programmer error just repeats the bug.
Practical guidance:
- Handle operational errors explicitly and locally.
- Let programmer errors bubble to the top; use
uncaughtException/unhandledRejectiononly to log and restart. - Custom error classes (below) help you distinguish the two (e.g., an
isOperationalflag).
Answer:
Custom error classes carry structured context beyond a message:
class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.isOperational = true; // distinguishes expected errors from bugs
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource) { super(`${resource} not found`, 404); }
}
class ValidationError extends AppError {
constructor(msg) { super(msg, 400); }
}
Propagating in Express:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.getUser(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
} catch (err) {
next(err); // hand off to the error middleware
}
});
(An asyncHandler wrapper can remove the repetitive try/catch by catching rejections and calling next for you. Express 5 forwards async rejections automatically.)
Centralized error-handling middleware — note the four parameters (that's how Express recognizes it), registered last:
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
// Log full details server-side; return a safe message to the client
if (!err.isOperational) logger.error(err);
res.status(status).json({ error: err.message });
});
Benefits: one place to format responses, log, and hide internal details; handlers stay focused on the happy path; the isOperational flag lets you decide what to expose vs. treat as a crash.
Answer:
Graceful shutdown ensures a deploy, scale-down, or Ctrl+C doesn't drop in-flight requests or leave resources dangling.
The sequence:
- Catch termination signals (
SIGTERMfrom orchestrators/containers,SIGINTfrom Ctrl+C). - Stop accepting new work —
server.close()stops new connections but lets in-flight requests finish. - Drain / close resources — DB pools, Redis, message-queue consumers, open files.
- Exit with code 0, or force-exit after a timeout if something hangs.
const server = app.listen(3000);
async function shutdown(signal) {
console.log(`${signal} received, shutting down...`);
// Stop accepting new connections; finish in-flight ones
server.close(async () => {
try {
await db.end(); // close DB pool
await redis.quit(); // close cache
process.exit(0);
} catch (err) {
console.error('Error during shutdown', err);
process.exit(1);
}
});
// Safety net: don't hang forever
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Why it matters:
- Kubernetes sends
SIGTERMthen waits (terminationGracePeriodSeconds) beforeSIGKILL. HandlingSIGTERMlets running requests complete during a rolling deploy → zero dropped requests. - Closing DB/queue connections cleanly avoids leaked connections and half-processed messages.
.unref()on the timeout ensures it doesn't itself keep the process alive.
Tip: also fail readiness checks first so the load balancer stops routing new traffic before you close the server.