A try...catch can appear to protect one function, yet a rejected promise, stream error, or failed database call may escape or reveal too much to an API client. One mental model and one reproducible HTTP example separate expected request failures from unexpected defects. Choose the correct error channel, preserve an internal cause, send a safe response, and test failures, not only success.
How errors travel through a Node.js program
The official Node.js Errors documentation defines four forms: synchronous throw, promise rejection, error-first callback, and EventEmitter or stream error event. One surrounding try...catch does not cover all four.
Worked event | Kind | Application decision | Public result |
|---|---|---|---|
| Invalid input | Map to | Safe validation message |
| Expected not found | Map to | Safe not-found message |
| Dependency refusal | Map to | Safe generic message |
Impossible internal state | Programmer defect | Log as fatal or unexpected | Never relabel as a client error |
The client gets a stable code and safe message; logs retain the stack, request ID, and cause.
Define one application error contract
Use one class to carry application meaning while retaining the standard Error contract:
class AppError extends Error {
constructor({ message, statusCode = 500, code = 'INTERNAL_ERROR', expose = false, cause }) {
super(message, { cause });
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.expose = expose;
}
}The official Errors documentation defines the standard message, stack, and cause properties. The main expected failure is new AppError({ message: 'Order 42 was not found.', statusCode: 404, code: 'ORDER_NOT_FOUND', expose: true }). Here, statusCode controls HTTP transport, code is stable for clients and tests, and message is public only when expose is true. cause preserves the original dependency error for logs.
Do not throw strings, make a user-visible message the only machine contract, or send stack and cause in JSON. At the outer request boundary, normalise a plain unexpected Error to a non-exposed 500 INTERNAL_ERROR.
Catch the error at the channel that delivers it
A synchronous validator throws directly:
function parseOrderId(raw) {
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) {
throw new AppError({
message: `Order ID "${raw}" must be a positive integer.`,
statusCode: 400,
code: 'INVALID_ORDER_ID',
expose: true
});
}
return id;
}parseOrderId('42') returns numeric 42. parseOrderId('abc') throws 400 INVALID_ORDER_ID with Order ID "abc" must be a positive integer.
For promises, place await loadOrder(id) inside try...catch. A floating loadOrder(id) call is neither awaited nor returned, so its rejection can escape the request path. An error-first callback handles its own first argument: readConfig((error, value) => { if (error) return handle(error); use(value); }).
For a stream, attach stream.on('error', handle). The official Node.js Events documentation says an error event without a listener throws, prints a stack trace, and exits the process. An outer try...catch also finishes before a later setTimeout callback runs. Catch inside that callback, or call a promise-returning function and await its rejection. Match the handler to the delivery channel instead of adding nested catches blindly.
Fully worked example: GET /orders/:id
Save the AppError, parseOrderId, and server code in one server.js file. The built-in node:http module handles the request path, the server listens on port 3000, and the map contains only order 7. Send each request with the header x-request-id: req-1042.
const http = require('node:http');
const orders = new Map([[7, { id: 7, status: 'paid' }]]);
function sendJson(res,statusCode,body) {
res.writeHead(statusCode, {'content-type':'application/json'});
res.end(JSON.stringify(body));
}
async function loadOrder(id) {
if (id === 500) {
const cause = new Error('connect ECONNREFUSED 127.0.0.1:5432');
cause.code = 'ECONNREFUSED';
throw new AppError({message:'Order store unavailable.',statusCode:503,
code:'ORDER_STORE_UNAVAILABLE',cause});
}
return orders.get(id) ?? null;
}
function handleError(error,res,requestId) {
const appError=error instanceof AppError?error:
new AppError({message:'Unexpected request failure.',cause:error});
console.error({requestId,code:appError.code,status:appError.statusCode,
cause:appError.cause,error:appError});
const message=appError.expose?appError.message:
appError.statusCode===503?'Service temporarily unavailable.':
'Internal server error.';
return sendJson(res,appError.statusCode,
{error:{code:appError.code,message,requestId}});
}
async function handleRequest(req, res) {
const requestId = req.headers['x-request-id'];
try {
const url = new URL(req.url, 'http://localhost');
const rawId = url.pathname.split('/').filter(Boolean).at(-1);
const id = parseOrderId(rawId);
const order = await loadOrder(id);
if (order === null) {
throw new AppError({message:`Order ${id} was not found.`,statusCode:404,
code:'ORDER_NOT_FOUND',expose:true});
}
return sendJson(res,200,{data:order,requestId});
} catch (error) {
return handleError(error, res, requestId);
}
}
const server = http.createServer(handleRequest);
server.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});GET /orders/7 returns status 200 and {"data":{"id":7,"status":"paid"},"requestId":"req-1042"}. For the main failure, GET /orders/42 parses 42, finds no entry because the map has only ID 7, throws ORDER_NOT_FOUND, and returns status 404 with exactly {"error":{"code":"ORDER_NOT_FOUND","message":"Order 42 was not found.","requestId":"req-1042"}}.

Preserve an internal cause without leaking it
For /orders/500, the private log retains requestId: 'req-1042', application code ORDER_STORE_UNAVAILABLE, status 503, cause code ECONNREFUSED, and cause message connect ECONNREFUSED 127.0.0.1:5432. The public response is exactly {"error":{"code":"ORDER_STORE_UNAVAILABLE","message":"Service temporarily unavailable.","requestId":"req-1042"}}. It contains neither 127.0.0.1 nor 5432.
Wrapping with cause adds transport meaning without discarding diagnostic context. An HTTP status differs from the network or storage failure beneath it, as Application Layer Protocols: DNS Walk-Through, HTTP explains. TCP vs UDP: Comparison Table, Headers, Exam Angle is optional background on that distinction.
Do not retry every failure. Invalid input and not-found results are not candidates. A dependency retry must be bounded and safe for the operation.

Treat process-level failures as a last boundary
Request validation, promise rejections, callback errors, and stream errors belong in their local paths. Do not delegate them to process.on('uncaughtException', ...) or process.on('unhandledRejection', ...) as routine control flow.
The official Node.js Process documentation warns that resuming normal operation after uncaughtException is unsafe. For an uncaught defect, record the full error, stop accepting new work, attempt bounded cleanup, and terminate so a supervisor can restart the process. Do not assume every asynchronous cleanup step will finish. Restart supervision and health checks are deployment responsibilities. At code level, keep anticipated failures within their request, callback, promise, or stream paths instead of sending them to this fatal boundary.
Test the failure contract and fix common traps
Make four library-independent integration assertions:
/orders/7returns200,{ id: 7, status: 'paid' }, and request IDreq-1042./orders/abcreturns400 INVALID_ORDER_IDwithOrder ID "abc" must be a positive integer./orders/42returns the exact404body shown above./orders/500returns the exact503body shown above and excludesECONNREFUSED,127.0.0.1,5432,stack, andcause.
The /orders/500 log must contain requestId: 'req-1042', code: 'ORDER_STORE_UNAVAILABLE', and cause.code: 'ECONNREFUSED'; sanitising must not erase diagnostics.
Trap and cause | Symptom | Correction |
|---|---|---|
Empty | Failure disappears | Log or rethrow with context |
Unawaited, unreturned promise | Rejection escapes | Preserve the chain |
Execution continues after a response | Second write |
|
Thrown string |
| Throw |
Every error becomes status | False success | Keep the stated mapping |
Node.js error handling: the short version and next step
Identify the delivery channel.
Translate expected failures into a stable
AppError.Preserve the original
cause.Expose only safe messages.
Test logs and responses separately.
With the server running, request IDs 7, abc, 42, and 500 in that order. Predict each status before checking the output. Node.js, Express.js & MongoDB Course is the strongest next step for backend practice. Use the Complete JavaScript Course if promises, classes, or template literals need revision. The Coding & Skill Development Courses page is the broader route.




