obah sylva

JavaScript Error Handling: Try, Catch, and Best Practices

Share:fXinW
Share this article

Unhandled errors are the difference between an app that shows a friendly “something went wrong” message and one that shows a blank white screen or crashes a Node.js server entirely. JavaScript’s error handling tools are simple on the surface — try, catch, finally — but the details around async code, error types, and what to actually do once you’ve caught an error trip up developers at every level.

The Basics: Try, Catch, and Finally

A try block runs code that might fail; if it throws, execution jumps immediately to the catch block instead of crashing the program. finally runs regardless of whether an error occurred, making it the right place for cleanup that must always happen.

try {
  const data = JSON.parse(userInput);
  console.log(data);
} catch (error) {
  console.error('Invalid JSON:', error.message);
} finally {
  console.log('Parsing attempt finished');
}

Catching Errors in Async Code

This is where most error handling actually breaks down. try/catch does not automatically catch errors inside callbacks or unresolved promises — it only catches synchronous throws and, with async/await, awaited promise rejections.

// This does NOT catch the error — setTimeout runs later, outside the try block
try {
  setTimeout(() => { throw new Error('Too late'); }, 100);
} catch (error) {
  console.log('Never runs'); // unreachable
}

// This DOES catch it, because await pauses until the promise settles
async function fetchUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (error) {
    console.error('Fetch failed:', error.message);
    throw error; // re-throw if the caller needs to know too
  }
}

Code editor screen displaying JavaScript error handling code

Custom Error Classes

The built-in Error object works, but extending it lets you distinguish error types and attach useful context, which matters a lot once an app has more than a handful of possible failure modes.

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

function validateAge(age) {
  if (age < 0) throw new ValidationError('Age cannot be negative', 'age');
  return age;
}

try {
  validateAge(-5);
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(`Invalid field: ${error.field}`);
  } else {
    throw error; // unknown error type, let it propagate
  }
}

Best Practices That Actually Matter

  • Never catch an error just to swallow it silently — an empty catch block hides bugs instead of fixing them and makes production issues nearly impossible to debug
  • Catch errors as close to where you can meaningfully handle them as possible, and let errors you can’t handle propagate upward rather than catching everything indiscriminately
  • Always add a global handler for truly unexpected errors: window.onerror and window.addEventListener(‘unhandledrejection’) in browsers, process.on(‘uncaughtException’) and process.on(‘unhandledRejection’) in Node.js
  • Log enough context to actually debug the issue later — the error message alone is rarely enough; include relevant input values, user actions, or request IDs
  • Distinguish between operational errors (a failed API call, invalid user input) that your app should handle gracefully, and programmer errors (a bug) that usually shouldn’t be caught and hidden

A Common Mistake: Catching Too Broadly

Wrapping an entire function body in one giant try/catch often does more harm than good — it obscures exactly which line failed and tempts developers into writing generic catch blocks that handle every possible error the same way, when a network timeout and a typo in a variable name need completely different responses.

Smaller, targeted try blocks around the specific operations that can actually fail — a fetch call, a JSON.parse, a database query — make error handling both more precise and easier to reason about later.

Frequently Asked Questions

Why doesn’t try/catch work inside a setTimeout or a promise callback?
Because the try block finishes executing (and exits) before the asynchronous callback ever runs; the error is thrown later, in a completely different execution context that the try/catch is no longer listening to. Using async/await with a properly awaited promise fixes this.
Is it bad practice to have an empty catch block?
Yes, almost always. An empty catch block silently swallows the error, meaning the failure still happened but now leaves no trace to debug it later; at minimum, log the error even if you don’t have a specific recovery action.
Should I create custom error classes for a small project?
For a small script, probably not necessary. Once an app has multiple distinct failure modes that need different handling (validation errors vs. network errors vs. permission errors), custom error classes make the code significantly easier to reason about.

Want more JavaScript deep dives like this? Explore more Web Development articles.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top