15 questions found
What is the difference between operational errors and programmer errors in Node.js, and why does this distinction matter?
Beginner
Operational errors are expected, recoverable failures that arise from the runtime environment or external conditions (a failed network request, invalid user input, a database connection drop) -- these should be handled gracefully, typically returning an appropriate error response. Programmer errors are actual bugs in the code (calling a method on undefined, a typo, incorrect logic) that indicate the program is in an unknown, potentially corrupted state -- these generally should NOT be caught and silently continued from, since doing so risks the application continuing in an inconsistent state; the safer response is usually to let the process crash and restart cleanly.
// Operational error: expected, handle gracefully
try {
const user = await db.users.findById(id);
} catch (err) {
if (err.code === 'CONNECTION_TIMEOUT') return res.status(503).json({ error: 'Service temporarily unavailable' });
}
// Programmer error: a bug, shouldn't be silently swallowed
// TypeError: Cannot read properties of undefined -- indicates broken code, not a recoverable condition
Real-world example
A team's error-handling strategy treats a failed payment-gateway API call (operational) by retrying and showing the user a friendly error message, while a TypeError from accessing a property on an unexpectedly undefined object (programmer error) is allowed to crash the process, triggering an automatic restart and an alert to fix the actual bug.
Common follow-ups: Why is it dangerous to catch and ignore a programmer error like a TypeError rather than letting the process crash?;How do custom error classes help distinguish operational from programmer errors at the point they're caught?
Advanced Node.js;Debugging & Diagnostics
How would you create and use custom Error subclasses in a Node.js application to represent different categories of application-specific errors?
Intermediate
Extending the built-in Error class lets you attach additional context (an HTTP status code, an error code, whether the error is operational) and lets calling code distinguish error types via instanceof checks, rather than parsing error message strings, which is fragile and prone to breaking when messages change.
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
this.statusCode = 400;
this.isOperational = true;
}
}
if (!email.includes('@')) throw new ValidationError('Invalid email format', 'email');
Real-world example
An API's global error-handling middleware checks 'if (err instanceof ValidationError)' to return a 400 response with the specific invalid field, versus a generic 500 for unrecognized error types, giving API consumers precise, structured error information rather than a generic failure message.
Common follow-ups: How do you ensure a custom Error subclass's stack trace still correctly points to where the error was thrown, not where it was constructed?;What's the risk of creating too many overly specific custom error classes versus a more generalized approach with an error 'code' property?
Advanced Node.js;RESTful API Design with Express
What is a centralized error-handling middleware pattern in Express, and what advantages does it provide over handling errors in each individual route?
Advanced
Express supports a special error-handling middleware signature (with four parameters: err, req, res, next) placed at the end of the middleware chain -- any error passed to next(err) anywhere in the request pipeline (or thrown inside an async route wrapped appropriately) is routed to this single centralized handler, letting error formatting, logging, and status-code mapping happen consistently in one place rather than duplicated across every route handler.
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUserOrThrow(req.params.id);
res.json(user);
} catch (err) {
next(err); // delegate to centralized handler
}
});
app.use((err, req, res, next) => { // must have exactly 4 params
logger.error(err);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({ error: err.isOperational ? err.message : 'Internal server error' });
});
Real-world example
An API refactors dozens of route handlers, each with their own duplicated try/catch error-formatting logic, to instead call next(err) and rely on a single centralized error-handling middleware, immediately making error responses consistent across the entire API and simplifying every individual route handler.
Common follow-ups: Why does Express require an error-handling middleware to have exactly four parameters to recognize it as such?;How do you ensure an unhandled error inside an async route handler actually reaches this middleware, given Express doesn't automatically catch async errors by default in older versions?
Express & Middleware;RESTful API Design with Express
Why doesn't Express automatically catch errors thrown inside an async route handler in versions prior to Express 5, and how do you work around this?
Intermediate
In Express 4 and earlier, if an async function passed as a route handler throws (or its returned promise rejects), Express's underlying error-handling doesn't automatically catch it the way it does for synchronous throws, because Express predates widespread async/await and doesn't await the handler's returned promise -- the common workaround wraps every async handler in a helper that catches rejections and forwards them to next(), or uses a library like express-async-errors that patches this behavior globally.
// Manual wrapper approach
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 getUserOrThrow(req.params.id); // if this throws, asyncHandler catches and forwards it
res.json(user);
}));
Real-world example
A team on Express 4 noticed that an unhandled rejection inside an async route handler was crashing their entire server rather than being caught by their error middleware, until they wrapped every async route with a small asyncHandler utility that properly forwards rejected promises to Express's next() function.
Common follow-ups: How does Express 5 change this default behavior regarding async route handlers?;What's the risk of forgetting to wrap even a single async route handler in a codebase relying on this manual pattern?
Express & Middleware;Async Patterns
What is the difference between a 4xx and 5xx HTTP status code from an error-handling perspective, and how should a Node.js API decide which to return?
Advanced
4xx status codes indicate the client made a mistake (invalid input, missing authentication, requesting a nonexistent resource) -- these are typically operational errors that should include a clear, actionable message helping the client fix their request. 5xx status codes indicate a server-side failure (an unexpected exception, a database outage) -- the specific internal details generally should NOT be exposed to the client (to avoid leaking implementation details or security-sensitive information), typically returning a generic 'Internal Server Error' message while logging full details server-side for debugging.
app.use((err, req, res, next) => {
if (err.isOperational) {
return res.status(err.statusCode || 400).json({ error: err.message }); // safe to show details
}
logger.error('Unexpected error:', err); // log full details internally
res.status(500).json({ error: 'Internal server error' }); // generic message to the client
});
Real-world example
An API returns a specific 400 error with 'Email is already registered' for a validation failure (safe, helpful to show), but for an unexpected database connection error, returns only a generic 500 'Internal server error' to the client while logging the full stack trace and connection details internally for the engineering team.
Common follow-ups: What specific security risk does leaking a stack trace or internal error message to an API client actually pose?;How would you handle an error that's genuinely ambiguous between being the client's fault and the server's fault?
Security;RESTful API Design with Express
How do you correctly propagate and handle errors from a Node.js stream, given that stream errors aren't caught by a surrounding try/catch?
Intermediate
Because streams are event-driven and much of their work happens asynchronously outside the synchronous call stack, wrapping stream operations in a try/catch doesn't catch errors emitted by the stream -- you must attach an explicit 'error' event listener to each stream in a pipeline, or use stream.pipeline() (rather than .pipe()), which properly propagates errors from any stream in the chain to a single callback or a rejected promise.
// try/catch does NOT catch this -- it's an async event, not a synchronous throw
try {
fs.createReadStream('missing-file.txt').pipe(res);
} catch (err) { /* never reached */ }
// Correct: explicit error listener, or use pipeline()
const { pipeline } = require('node:stream/promises');
try {
await pipeline(fs.createReadStream('missing-file.txt'), res);
} catch (err) {
console.error('Stream failed:', err.message); // correctly caught
}
Real-world example
A file-download endpoint that silently hung whenever the source file didn't exist (because the error from createReadStream was never actually handled) was fixed by switching from .pipe() to stream.pipeline(), which properly surfaces the file-not-found error as a rejected promise the route handler can catch and respond to.
Common follow-ups: Why specifically does a try/catch fail to catch an error emitted asynchronously by an EventEmitter-based API like streams?;What happens to a stream pipeline if one stream in the middle errors but the others don't have error listeners attached?
Streams & Buffers;Advanced Node.js
What is error wrapping (or error chaining), and how does the Error class's 'cause' property support it in modern Node.js?
Advanced
Error wrapping catches a lower-level error and re-throws a new, higher-level error that adds context relevant to the current layer of the application, while preserving the original error so its details aren't lost -- the standardized 'cause' option (available via `new Error(message, { cause: originalError })`) provides a built-in way to do this without needing a custom convention, letting downstream code and logging tools walk the full chain of causation from a high-level error back to its root cause.
async function getUserProfile(id) {
try {
return await db.users.findById(id);
} catch (err) {
throw new Error('Failed to load user profile', { cause: err }); // preserves the original error
}
}
// Later, when logging or debugging:
console.error(err.message, 'caused by:', err.cause?.message);
Real-world example
A team debugging a confusing 'Failed to load user profile' error in production is able to trace it back to the actual root cause -- a database connection timeout -- because the error was wrapped using the 'cause' option rather than the original error being discarded and replaced entirely.
Common follow-ups: How did teams handle this same error-wrapping need before the standardized 'cause' property was introduced?;How do logging libraries and error-tracking tools (like Sentry) typically surface and display a full error cause chain?
Debugging & Diagnostics;Logging & Monitoring
What is the try/catch/finally pattern's 'finally' block used for, and how does it behave with async/await?
Intermediate
The 'finally' block runs regardless of whether the try block succeeded or the catch block handled an error, making it the natural place for cleanup logic (closing a database connection, releasing a lock, stopping a loading spinner) that must happen in every case -- with async/await, a finally block correctly waits for the try/catch's async operations to settle before running, and importantly, an async operation inside finally itself is also properly awaited if the finally block itself is async.
async function processWithConnection(id) {
const connection = await pool.connect();
try {
return await connection.query('SELECT ...', [id]);
} catch (err) {
logger.error(err);
throw err;
} finally {
connection.release(); // always runs, whether the query succeeded or failed
}
}
Real-world example
A database query wrapper always releases its acquired connection back to the pool inside a finally block, guaranteeing the connection is returned regardless of whether the query succeeds, throws a validation error, or throws an unexpected database error.
Common follow-ups: What happens if the finally block itself throws an error -- does it override an error from the try or catch block?;Why is placing cleanup logic in finally more reliable than duplicating it at the end of both the try and catch blocks?
Databases & ORMs (MongoDB/Mongoose
SQL/Sequelize);Async Patterns
How would you implement a global error boundary for an entire Node.js application to ensure no error type goes completely unhandled?
Advanced
A comprehensive strategy layers multiple safety nets: Express's centralized error-handling middleware for errors within the request/response cycle, a process-level 'unhandledRejection' handler for promise rejections that escape all other handling, an 'uncaughtException' handler for synchronous errors that escape everything else (used only for logging and clean shutdown, never to keep running), and structured logging at each layer so every error is captured with enough context to diagnose, regardless of where in the application it originated.
app.use((err, req, res, next) => { logger.error(err); res.status(500).json({ error: 'Internal server error' }); });
process.on('unhandledRejection', (reason) => { logger.error('Unhandled rejection:', reason); process.exit(1); });
process.on('uncaughtException', (err) => { logger.error('Uncaught exception:', err); process.exit(1); });
Real-world example
A production Node.js service layers all three of these safety nets, ensuring that regardless of whether an error occurs inside a request handler, an unawaited background promise, or a genuinely unexpected synchronous bug, it's always logged with full context before the process either recovers gracefully or restarts cleanly under a process manager.
Common follow-ups: Why is it important that both unhandledRejection and uncaughtException handlers still call process.exit() rather than just logging and continuing?;How does a process manager like PM2 or Kubernetes ensure the application actually restarts quickly after one of these handlers exits the process?
Advanced Node.js;Deployment & Process Managers (PM2)
What is the risk of exposing a raw error's stack trace in an API response, and how do you prevent it while still logging full details server-side?
Intermediate
A stack trace can reveal internal file paths, library versions, database query structure, or other implementation details that could help an attacker understand and exploit the system further -- production error handlers should log the full error (including stack trace) server-side for the engineering team, while returning only a sanitized, generic message to the actual API client, typically gated by an environment check to still show full details during local development for convenience.
app.use((err, req, res, next) => {
logger.error(err.stack); // full details, server-side only
const isDev = process.env.NODE_ENV !== 'production';
res.status(500).json({
error: 'Internal server error',
...(isDev && { stack: err.stack }), // only include stack trace outside production
});
});
Real-world example
A penetration test flags that a company's production API was returning full stack traces (revealing the exact file structure and an outdated library version) in error responses; the fix gates stack-trace exposure to only occur when NODE_ENV isn't 'production'.
Common follow-ups: What other information beyond a raw stack trace should be scrubbed from error responses before they reach a client?;How would you configure a logging tool to still capture this same detailed information without it ever reaching the actual HTTP response?
Security;Logging & Monitoring