Skip to main content
JavaScript intermediate Lesson 11 of 24

Error Handling in JavaScript

Learn how to handle errors in JavaScript — try/catch/finally, built-in error types, custom error classes, rethrowing, and async error patterns.

Good error handling is the difference between an app that crashes silently and one that tells you exactly what went wrong. JavaScript gives you several layers to work with — synchronous try/catch, async .catch(), and global handlers for anything that slips through. The goal is to catch errors at the level where you can do something useful with them, and propagate everything else upward.

try / catch / finally

try/catch is the foundational mechanism for handling errors in synchronous code. The catch block only runs if an exception is thrown inside the try block; the finally block runs regardless — making it the right place for cleanup code that must execute whether or not an error occurred.

function parseJSON(raw) {
  try {
    return JSON.parse(raw);
  } catch (err) {
    // JSON.parse throws a SyntaxError on invalid input
    console.error("Invalid JSON:", err.message);
    return null; // return a safe fallback instead of crashing
  }
}

parseJSON('{"name":"Alice"}'); // { name: "Alice" }
parseJSON("not json");         // logs error, returns null

finally runs regardless of whether an error was thrown — perfect for releasing resources like file handles or database connections:

function readConfig(path) {
  let file;
  try {
    file = openFile(path);       // hypothetical synchronous API
    return JSON.parse(file.read());
  } catch (err) {
    // Add context before rethrowing — the original error alone may not say which file failed
    throw new Error(`Config error at ${path}: ${err.message}`);
  } finally {
    file?.close(); // always close the file — even if an error was thrown
  }
}

Built-in Error Types

JavaScript ships with several specialised error types, all extending Error. Knowing which type to expect helps you write more precise catch blocks — for example, a SyntaxError from JSON.parse should be handled differently from a TypeError caused by a null dereference.

// TypeError — wrong type or null/undefined access
try {
  null.toString();
} catch (e) {
  console.log(e instanceof TypeError); // true
  console.log(e.name);    // "TypeError"
  console.log(e.message); // "Cannot read properties of null..."
}

// RangeError — value out of allowed range
try {
  new Array(-1);
} catch (e) {
  console.log(e instanceof RangeError); // true
}

// ReferenceError — accessing a variable that doesn't exist
try {
  console.log(undeclaredVariable);
} catch (e) {
  console.log(e instanceof ReferenceError); // true
}

// SyntaxError — only throwable at runtime from eval() or JSON.parse()
try {
  JSON.parse("{bad json}");
} catch (e) {
  console.log(e instanceof SyntaxError); // true
}

// URIError — malformed argument to encodeURI/decodeURI
try {
  decodeURIComponent("%");
} catch (e) {
  console.log(e instanceof URIError); // true
}

Checking error type in a catch block

Using instanceof lets you handle different error types differently in the same catch block, rather than treating all errors identically:

function handleError(err) {
  if (err instanceof TypeError) {
    console.error("Type problem:", err.message);
  } else if (err instanceof RangeError) {
    console.error("Out of range:", err.message);
  } else {
    // Unknown — rethrow so it isn't silently swallowed
    throw err;
  }
}

Custom Error Classes

Extending Error lets you create domain-specific errors that carry structured context — status codes, field names, error codes — rather than just a message string. This makes error handling at API boundaries much cleaner, and lets you instanceof check for specific error types rather than parsing message strings.

class AppError extends Error {
  constructor(message, code, statusCode = 500) {
    super(message);
    this.name = "AppError";
    this.code = code;
    this.statusCode = statusCode;

    // Maintain a proper stack trace in V8 (Node.js / Chrome)
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, AppError);
    }
  }
}

class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} with id ${id} not found`, "NOT_FOUND", 404);
    this.name = "NotFoundError";
    this.resource = resource;
    this.id = id;
  }
}

class ValidationError extends AppError {
  constructor(field, message) {
    super(message, "VALIDATION_ERROR", 400);
    this.name = "ValidationError";
    this.field = field;
  }
}

// Usage — throw rich errors with structured context
function getUser(id) {
  const user = db.find(id);
  if (!user) throw new NotFoundError("User", id);
  return user;
}

function validateEmail(email) {
  if (!email.includes("@")) {
    throw new ValidationError("email", "Email must contain @");
  }
}

Handling custom errors

Custom error classes pay off at the boundary where errors become HTTP responses or UI messages — you can branch cleanly on error type rather than matching against fragile message strings:

function handleRequest(id, email) {
  try {
    validateEmail(email);
    const user = getUser(id);
    return { ok: true, user };
  } catch (err) {
    if (err instanceof ValidationError) {
      return { ok: false, status: 400, field: err.field, message: err.message };
    }
    if (err instanceof NotFoundError) {
      return { ok: false, status: 404, message: err.message };
    }
    // Unexpected error — rethrow to avoid swallowing bugs
    throw err;
  }
}

Rethrowing Errors

Only catch what you can meaningfully handle. Rethrowing preserves the original stack trace and ensures unexpected errors propagate to a handler that can deal with them, rather than disappearing silently into an empty catch block.

async function loadUserProfile(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new AppError(`HTTP ${response.status}`, "HTTP_ERROR", response.status);
    }
    return await response.json();
  } catch (err) {
    // Translate network errors into a domain error with a consistent shape
    if (err instanceof TypeError && err.message.includes("fetch")) {
      throw new AppError("Network unavailable", "NETWORK_ERROR", 503);
    }
    // For everything else, rethrow as-is — don't hide unexpected failures
    throw err;
  }
}

Async Error Handling

Async errors follow the same principles as synchronous ones, but the mechanics differ depending on whether you’re using async/await or Promise chains. The key rule: always attach a rejection handler, either via try/catch around await, or via .catch() on the Promise.

try / catch with async / await

async function fetchData(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
    return await res.json();
  } catch (err) {
    console.error("fetchData failed:", err.message);
    return null; // return a safe fallback
  }
}

.catch() on Promises

fetch("/api/data")
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .then(data => console.log(data))
  .catch(err => console.error("Request failed:", err.message));

Handling multiple async operations

async function loadDashboard(userId) {
  // Promise.all — run in parallel, fail fast if any operation fails
  const [profile, posts, settings] = await Promise.all([
    fetchProfile(userId),
    fetchPosts(userId),
    fetchSettings(userId),
  ]);
  return { profile, posts, settings };
}

// Promise.allSettled — run in parallel, allow partial success
async function loadDashboardSafe(userId) {
  const results = await Promise.allSettled([
    fetchProfile(userId),
    fetchPosts(userId),
    fetchSettings(userId),
  ]);

  const [profile, posts, settings] = results.map(r =>
    r.status === "fulfilled" ? r.value : null // null signals a failed widget
  );

  return { profile, posts, settings };
}

Async error utility

This attempt wrapper trades try/catch boilerplate for a Go-style [error, value] tuple, which some developers find easier to scan in functions that call many async operations:

// Wraps a promise and always resolves to [error, data]
async function attempt(promise) {
  try {
    const data = await promise;
    return [null, data];
  } catch (err) {
    return [err, null];
  }
}

// Usage — no nested try/catch needed
const [err, user] = await attempt(fetchUser(id));
if (err) {
  console.error("Could not load user:", err.message);
} else {
  console.log(user.name);
}

Global Error Handlers

Global handlers catch errors that slip through all other handlers — unhandled rejections, uncaught exceptions, and errors in third-party code. In production, these should send to an error tracking service (Sentry, Datadog, etc.) rather than just logging.

// Browser — catch uncaught synchronous errors
window.addEventListener("error", event => {
  console.error("Uncaught error:", event.error);
  // send to error tracking service
});

// Browser — catch unhandled Promise rejections
window.addEventListener("unhandledrejection", event => {
  console.error("Unhandled promise rejection:", event.reason);
  event.preventDefault(); // suppress the default browser console warning
});

// Node.js
process.on("uncaughtException", err => {
  console.error("Uncaught exception:", err);
  process.exit(1); // mandatory — application state may be corrupt
});

process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled rejection at:", promise, "reason:", reason);
});

Real-World: Service Layer Error Handling

A service layer sits between your HTTP routes and the database. It’s the right place to validate inputs, wrap database errors, and throw typed errors that route handlers can translate into HTTP responses.

class UserService {
  async createUser({ email, password, name }) {
    // Validate inputs — throw before touching the database
    if (!email?.includes("@")) throw new ValidationError("email", "Invalid email");
    if (!password || password.length < 8) {
      throw new ValidationError("password", "Password must be at least 8 characters");
    }

    // Wrap database calls — translate infrastructure errors into domain errors
    let existing;
    try {
      existing = await db.users.findByEmail(email);
    } catch (err) {
      throw new AppError("Database unavailable", "DB_ERROR", 503);
    }

    if (existing) throw new AppError("Email already registered", "DUPLICATE_EMAIL", 409);

    try {
      const hash = await bcrypt.hash(password, 12);
      return await db.users.create({ email, password: hash, name });
    } catch (err) {
      throw new AppError(`Failed to create user: ${err.message}`, "CREATE_FAILED", 500);
    }
  }
}

Common Pitfalls

Empty catch blocks destroy debugging context:

// Never do this — errors disappear without a trace
try {
  riskyOperation();
} catch (e) {} // silent failure

// At minimum, log and rethrow
try {
  riskyOperation();
} catch (e) {
  console.error(e);
  throw e; // or handle meaningfully
}

finally runs even when you return inside try:

function test() {
  try {
    return "from try";
  } finally {
    return "from finally"; // overrides the try return — surprising!
  }
}
console.log(test()); // "from finally"
// Avoid returning from finally — use it only for cleanup

Forgetting await inside try/catch:

// Bug — the catch never fires because the Promise is not awaited
async function buggy() {
  try {
    const data = fetchData(); // missing await — returns a Promise, doesn't throw here
  } catch (err) {
    console.error(err); // never reached
  }
}

// Fix — await the Promise so rejections become catchable exceptions
async function fixed() {
  try {
    const data = await fetchData();
  } catch (err) {
    console.error(err);
  }
}

Frequently Asked Questions

When should I rethrow an error instead of handling it?
Rethrow when you can't meaningfully recover from the error at the current level. Catch errors only when you have something useful to do — log context, translate to a domain error, or show a user message. Silently swallowing errors is almost always wrong.
Why does try/catch not catch errors inside setTimeout or Promise callbacks?
try/catch is synchronous. By the time a setTimeout callback fires, the try/catch block has already exited. Use .catch() on Promises or try/catch inside async functions to handle asynchronous errors.
What is the difference between throw and return in a catch block?
return exits the function normally with a value — the error is considered handled. throw propagates the error to the next handler up the call stack. If you want callers to know something went wrong, throw; if you've handled it and have a fallback value, return.