How to buildOctober 11, 20267 min read
JavaScript from scratchStep 7 of 9

Error handling: try/catch, async errors and clear messages

In short

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.

FundamentalsLevel: IntermediateError Handling

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

Verified with

Node.js 24 LTS (24.18)JavaScript (ES2022+: Error.cause)

Checked against docs: October 11, 2026

Contents
  1. 01Errors as values and as exceptions
  2. 02throw, try/catch/finally
  3. 03Custom error classes and Error.cause
  4. 04Errors in promises and async/await
  5. 05Where to catch: at the boundary, not everywhere
  6. 06User messages versus developer logs
  7. 07Anti-patterns and exercises

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.

safe-parse.mjs
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);
}
Error as a value: the caller must check ok
terminal
$ node safe-parse.mjs
value: { qty: 2 }
invalid JSON: Expected property name or '}' in JSON at position 1 (line 1 column 2)
Real Node.js 24 output

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:

basics.mjs
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");
  }
}
Catch only the expected RangeError, pass everything else up
terminal
$ 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" checked
Real output: finally runs on every iteration
  • finally is for cleanup: close a file or connection, turn off a loading indicator, re-enable a button.
  • Do not put return or throw inside finally: MDN warns they override the result of try and catch, 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 in safeParseJson).
  • 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.

errors.mjs
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 {}
App error hierarchy: a common AppError and three specific cases

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:

load-menu.mjs
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;
}
Each kind of failure gets its own class; the original error is kept in cause

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:

cause-chain.mjs
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;
  }
}
Walking the cause chain
terminal
$ 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)
Real output: three levels of cause

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:

floating.mjs
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);
Bug: loadMenu called without await or .catch()
terminal
$ 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.0
Real output: the process crashed and the timer never fired; the path is shortened to …

Three 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.

fixed-refresh.mjs
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);
Fix: await inside try/catch
terminal
$ 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 s
Real output: the error is handled and the program keeps running

The 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”:

safety-net.mjs
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");
Global handler: log and a non-zero exit code
terminal
$ 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=1
Real output: with the handler the process did not crash, but exited with code 1

Where 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.

fetch failsloadMenu: NetworkError with causeboundary: catchlog: full detailsuser: what to do
The error rises without intermediate catches and is handled once — at the boundary.

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.

test-server.mjs
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)));
}
Test server on node:http — only for exercising the scenarios
main.mjs
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();
The boundary: a log for the developer, a message for the user, finally for cleanup
terminal
$ 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 off
Real output: four scenarios, the program never crashed

User 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:

user-message.mjs
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.";
}
One function maps an error class to a user message

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:

antipattern.mjs
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!");
Anti-example: an empty catch swallows a save error
terminal
$ node antipattern.mjs
Order accepted!
Real output: the order was not saved, yet the user sees “accepted”, exit code 0
  • No empty catch blocks, and no catch that only does console.log(error) and carries on as if nothing happened
  • You throw Error objects 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 fetch response is checked with res.ok — 404 and 500 do not throw by themselves
  • No return or throw inside finally
  • 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.