Handle an error where you have something to say about it: low-level code throws a specific error of its own class and keeps the original in cause, while the boundary — a request handler, a screen, a command — catches it, logs the details and shows the user a short message about what to do. In async code every promise must be awaited or have a .catch(): in Node.js an unhandled rejection terminates the process by default. An empty catch that swallows the error is worse than a crash.
After this you will be able to
- Handle synchronous and asynchronous errors with try/catch/finally and rethrow the ones your code cannot handle
- Create custom error classes and wrap the original error with Error.cause without losing it
- Explain why a floating promise crashes a Node.js process and spot such a promise from the output
- Separate the user-facing message from the log entry so technical details do not leak
Know beforehand
Verified with
Node.js 24 LTS (24.18)JavaScript (ES2022+: Error.cause)
Checked against docs: October 11, 2026
Contents
Errors as values and as exceptions
JavaScript has two ways to report that something went wrong. An exception interrupts execution: throw throws a value, and it bubbles up the call stack until the nearest catch catches it. If nothing does, the program crashes — the debugging lesson showed what that looks like. The second way is to return the error as an ordinary value, such as an object { ok: false, error }, and let the caller decide.
export function safeParseJson(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch (error) {
return { ok: false, error };
}
}
const results = ['{"qty": 2}', "{qty: 2}"].map(safeParseJson);
for (const result of results) {
if (result.ok) console.log("value:", result.value);
else console.log("invalid JSON:", result.error.message);
}$ node safe-parse.mjs
value: { qty: 2 }
invalid JSON: Expected property name or '}' in JSON at position 1 (line 1 column 2)Return a value { ok, error }
- When it fits
- The error is an expected outcome: the user typed invalid data, a file may be missing
- Risk
- Nobody checks the result and the error slips through
Throw an exception
- When it fits
- The function cannot do its job, and someone higher up the stack must decide
- Risk
- Nobody catches the exception and the program crashes
Built-in JavaScript and Node.js APIs mostly throw exceptions, so the rest of the lesson is mostly about them. Throw an Error object or a subclass, not a string: only an Error has stack, name and cause.
throw, try/catch/finally
try holds code that may throw; catch receives the thrown value; finally always runs — after a successful try, after catch, and even when there was a return inside. JavaScript has no typed catch clauses like Java, so you check the type yourself with instanceof and rethrow whatever you cannot handle:
function parseQty(input) {
const qty = Number(input);
if (!Number.isInteger(qty) || qty < 1) {
throw new RangeError(`qty must be a positive integer, got "${input}"`);
}
return qty;
}
for (const input of ["2", "0", "abc"]) {
try {
console.log("try:", parseQty(input));
} catch (error) {
if (!(error instanceof RangeError)) throw error;
console.log("catch:", error.name, "-", error.message);
} finally {
console.log("finally: input", JSON.stringify(input), "checked");
}
}$ node basics.mjs
try: 2
finally: input "2" checked
catch: RangeError - qty must be a positive integer, got "0"
finally: input "0" checked
catch: RangeError - qty must be a positive integer, got "abc"
finally: input "abc" checkedfinallyis for cleanup: close a file or connection, turn off a loading indicator, re-enable a button.- Do not put
returnorthrowinsidefinally: MDN warns they override the result oftryandcatch, and a thrown error can silently vanish. - If you do not need the error value, use
catch { … }without parentheses — but only when you really know what happened (as insafeParseJson). - A caught error is handled once — by the nearest
catch. For an outer handler to see it too, throw it again:throw error.
Custom error classes and Error.cause
Built-in TypeError or SyntaxError say what broke technically, not what it means for the app. Custom error classes give the handler something to decide on: “no connection — offer a retry”, “the server returned 500 — show a fallback”. MDN recommends extending Error and checking the type with instanceof. this.name = new.target.name stores the actual class name in name — it ends up in logs and stack traces.
export class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = new.target.name;
}
}
export class NetworkError extends AppError {}
export class HttpError extends AppError {
constructor(status, url, options) {
super(`HTTP ${status} for ${url}`, options);
this.status = status;
}
}
export class DataError extends AppError {}The second argument of the Error constructor is an options object with a cause field. Use it when you catch a low-level error and throw a clearer one instead: the original is not lost and stays available in error.cause. This is how the coffee shop menu loader works — every way it can fail becomes its own class:
import { DataError, HttpError, NetworkError } from "./errors.mjs";
export async function loadMenu(url) {
let res;
try {
res = await fetch(url, { signal: AbortSignal.timeout(3000) });
} catch (error) {
throw new NetworkError(`Request to ${url} failed`, { cause: error });
}
if (!res.ok) throw new HttpError(res.status, url);
let data;
try {
data = await res.json();
} catch (error) {
throw new DataError(`Response from ${url} is not JSON`, { cause: error });
}
if (!Array.isArray(data.items)) {
throw new DataError(`Response from ${url} has no "items" array`);
}
return data.items;
}You can walk the cause chain in a loop. A request to a port nobody listens on yields three levels: our NetworkError, TypeError: fetch failed from fetch, and a system error with code ECONNREFUSED:
import { loadMenu } from "./load-menu.mjs";
try {
await loadMenu("http://127.0.0.1:8082/menu");
} catch (error) {
let current = error;
while (current) {
console.log(`${current.name}: ${current.message}${current.code ? ` (${current.code})` : ""}`);
current = current.cause;
}
}$ node cause-chain.mjs
NetworkError: Request to http://127.0.0.1:8082/menu failed
TypeError: fetch failed
Error: connect ECONNREFUSED 127.0.0.1:8082 (ECONNREFUSED)Errors in promises and async/await
Inside an async function, throw becomes a rejected promise. await turns it back into an exception, so a plain try/catch around await catches async errors just like sync ones. Trouble starts when nobody awaits the promise. The function below starts a load and forgets about it — the promise is left floating:
import { loadMenu } from "./load-menu.mjs";
function refreshMenu() {
loadMenu("http://127.0.0.1:8082/menu");
console.log("refresh started");
}
refreshMenu();
setTimeout(() => console.log("next refresh in 1 s"), 1000);$ node floating.mjs
refresh started
file:///…/load-menu.mjs:8
throw new NetworkError(`Request to ${url} failed`, { cause: error });
^
NetworkError: Request to http://127.0.0.1:8082/menu failed
at loadMenu (file:///…/load-menu.mjs:8:11)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5) {
[cause]: [TypeError: fetch failed] {
[cause]: Error: connect ECONNREFUSED 127.0.0.1:8082
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1706:16) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 8082
}
}
}
Node.js v24.18.0Three things in this output. First, refresh started was printed — the function “successfully” returned before the request failed. Second, next refresh in 1 s never appeared — the process exited with code 1. Since Node.js 15 the default --unhandled-rejections mode is throw: an unhandled rejection becomes an uncaught exception. Third, Node.js prints the whole [cause] chain — that is why you keep it. Note that refreshMenu is missing from the stack: after await the stack shows only the async part, so a good error message (with the URL) is worth more than a long stack.
import { loadMenu } from "./load-menu.mjs";
import { toUserMessage } from "./user-message.mjs";
async function refreshMenu() {
try {
const items = await loadMenu("http://127.0.0.1:8082/menu");
console.log("menu:", items.join(", "));
} catch (error) {
console.error("[log]", error.name, error.message);
console.log("User sees:", toUserMessage(error));
}
}
await refreshMenu();
setTimeout(() => console.log("next refresh in 1 s"), 1000);$ node fixed-refresh.mjs
[log] NetworkError Request to http://127.0.0.1:8082/menu failed
User sees: No connection to the server. Check your internet and try again.
next refresh in 1 sThe last line of defence is a global handler: process.on("unhandledRejection") in Node.js or the unhandledrejection event in the browser. It exists to log what slipped through, not to “carry on as if nothing happened”:
process.on("unhandledRejection", (reason) => {
console.error("[fatal] unhandled rejection:", reason?.name, reason?.message);
console.error("[fatal] cause:", reason?.cause?.message ?? "-");
process.exitCode = 1;
});
await import("./floating.mjs");$ node safety-net.mjs; echo "exit=$?"
refresh started
[fatal] unhandled rejection: NetworkError Request to http://127.0.0.1:8082/menu failed
[fatal] cause: fetch failed
next refresh in 1 s
exit=1Where to catch: at the boundary, not everywhere
A common mistake is wrapping every function in try/catch. The code bloats, and errors still get lost or logged five times. A better rule: catch where you can do something about the error. Inner functions throw clear errors and do not decide what the user sees. The boundary decides — the place where code meets the outside world: an HTTP request handler, a button handler, the entry point of a command or a background job.
Let's put it together. The test server returns three responses — a normal menu, a 500 error and HTML instead of JSON — and the fourth URL points at a port with nobody listening. The boundary is the loop in main.mjs: one try/catch/finally per load.
import { createServer } from "node:http";
export function startTestServer(port) {
const server = createServer((req, res) => {
if (req.url === "/menu") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ items: ["latte", "flat white"] }));
}
if (req.url === "/broken") {
res.writeHead(500, { "Content-Type": "text/plain" });
return res.end("Internal Server Error");
}
if (req.url === "/html") {
res.writeHead(200, { "Content-Type": "text/html" });
return res.end("<!doctype html><h1>Maintenance</h1>");
}
res.writeHead(404).end();
});
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
}import { loadMenu } from "./load-menu.mjs";
import { toUserMessage } from "./user-message.mjs";
import { startTestServer } from "./test-server.mjs";
const server = await startTestServer(8081);
const urls = [
"http://127.0.0.1:8081/menu",
"http://127.0.0.1:8081/broken",
"http://127.0.0.1:8081/html",
"http://127.0.0.1:8082/menu",
];
for (const url of urls) {
console.log("→", url, "(spinner on)");
try {
const items = await loadMenu(url);
console.log(" OK:", items.join(", "));
} catch (error) {
console.error(" [log]", error.name, error.message, "| cause:", error.cause?.message ?? "-");
console.log(" User sees:", toUserMessage(error));
} finally {
console.log(" spinner off");
}
}
server.close();$ node main.mjs
→ http://127.0.0.1:8081/menu (spinner on)
OK: latte, flat white
spinner off
→ http://127.0.0.1:8081/broken (spinner on)
[log] HttpError HTTP 500 for http://127.0.0.1:8081/broken | cause: -
User sees: The menu is temporarily unavailable. Please try again in a minute.
spinner off
→ http://127.0.0.1:8081/html (spinner on)
[log] DataError Response from http://127.0.0.1:8081/html is not JSON | cause: Unexpected token '<', "<!doctype "... is not valid JSON
User sees: The menu could not be displayed. We are already looking into it.
spinner off
→ http://127.0.0.1:8082/menu (spinner on)
[log] NetworkError Request to http://127.0.0.1:8082/menu failed | cause: fetch failed
User sees: No connection to the server. Check your internet and try again.
spinner offUser messages versus developer logs
One error has two readers with different needs. The developer needs details: class, message, URL, the cause chain, the stack trace. The user needs to understand what happened to their task and what to do next. The OWASP Error Handling Cheat Sheet recommends not exposing implementation details — stacks, SQL, file paths and library versions help an attacker — and logging them server-side instead. Keeping the translation from technical error to human words in one function works well:
import { DataError, HttpError, NetworkError } from "./errors.mjs";
export function toUserMessage(error) {
if (error instanceof NetworkError) {
return "No connection to the server. Check your internet and try again.";
}
if (error instanceof HttpError && error.status >= 500) {
return "The menu is temporarily unavailable. Please try again in a minute.";
}
if (error instanceof HttpError && error.status === 404) {
return "Menu not found. Please refresh the page.";
}
if (error instanceof DataError) {
return "The menu could not be displayed. We are already looking into it.";
}
return "Something went wrong. Please try again later.";
}Content
- Developer log
- Class, message, URL, status, cause chain, stack trace
- User message
- What happened to the user's task and what to do
Language
- Developer log
- Technical, in English — easy to search
- User message
- The interface language, no jargon
Implementation details
- Developer log
- Yes, all of them
- User message
- No: no stack, no SQL, no file paths
Example
- Developer log
HttpError HTTP 500 for http://…/broken- User message
- “The menu is temporarily unavailable. Please try again in a minute.”
A good user message answers three questions: what failed, whose fault it is (theirs or ours) and what to do — retry, check the connection, refresh the page. If the user's input is wrong, show the message next to the field. For HTTP APIs the error response format is standardized in RFC 9457 (Problem Details): type, title, status and detail — without server internals.
Anti-patterns and exercises
The most dangerous anti-pattern is an empty catch. The program does not crash, which looks like robustness, but in fact the error disappears together with the data:
async function saveOrder(order) {
throw new Error(`DB is read-only, order ${order.id} not saved`);
}
const order = { id: 42 };
try {
await saveOrder(order);
} catch {}
console.log("Order accepted!");$ node antipattern.mjs
Order accepted!- No empty
catchblocks, and nocatchthat only doesconsole.log(error)and carries on as if nothing happened - You throw
Errorobjects and subclasses, not strings - When re-wrapping, the original error is kept in
cause - An error is logged once — at the boundary, not on every level
- Every promise is awaited, returned or has a
.catch() - A
fetchresponse is checked withres.ok— 404 and 500 do not throw by themselves - No
returnorthrowinsidefinally - The user sees a clear message without a stack or implementation details
How this was verified7 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.