Skip to main content

Command Palette

Search for a command to run...

How try-catch Can Make Debugging Harder

Learn What to Do Instead to make your debugging easier

Updated
4 min read
How try-catch Can Make Debugging Harder

Introduction

As MERN stack developers (MongoDB, Express, React, Node.js), we’re taught early that try-catch is the safety net for handling errors gracefully. And it is useful — but overusing or misusing try-catch can make debugging more painful than it needs to be.

In fact, many developers unknowingly “eat up” errors with try-catch, making them invisible or harder to trace. Let’s dive into how this happens, with examples, and discuss preventive measures and better practices.

How try-catch Eats Errors

When you wrap code in a try-catch, the error is caught. But if you don’t log it properly, or if you just return a generic response, you lose critical debugging information.

Example 1: Swallowing Errors in Express Route

// ❌ Anti-pattern
app.get('/user/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) throw new Error("User not found");
    res.json(user);
  } catch (err) {
    // Developer swallows error
    res.status(500).send("Something went wrong");
  }
});

Why this is bad:

  • The actual error (CastError, DB connection error, etc.) is hidden.

  • Debugging becomes hard because logs show nothing useful.

  • In production, you won’t know why things failed.

Example 2: Nested try-catch Hell

// ❌ Anti-pattern
try {
  try {
    riskyOperation();
  } catch (err) {
    // Re-throw or wrap in vague error
    throw new Error("Inner operation failed");
  }
} catch (err) {
  console.log("Outer error:", err.message);
}

Here, the original stack trace is lost. All you know is "Inner operation failed".

When you log err.message without err.stack, you remove the ability to trace the root cause.

Why This Makes Debugging Difficult

  1. Loss of stack trace – Without err.stack, you don’t know where the error originated.

  2. Generic error messages"Something went wrong" is meaningless in a large codebase.

  3. Silent failures – If errors are caught but not logged, the system fails quietly.

  4. Inconsistent error handling – Developers handle errors differently across files, making debugging unpredictable.

Preventive Measures & Better Practices

Instead of relying on raw try-catch everywhere, adopt structured error-handling practices.

Best Practice 1: Always Log the Full Error

catch (err) {
  console.error("Error in /user/:id route:", err); // includes stack trace
  res.status(500).json({ error: "Internal Server Error" });
}

Why:

  • Keeps logs informative.

  • Still sends a safe, generic message to the client.

Best Practice 2: Centralized Error Handling in Express

Instead of writing try-catch in every route:

// Wrapper to catch async errors
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Example route
app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new Error("User not found");
  res.json(user);
}));

// Centralized error handler
app.use((err, req, res, next) => {
  console.error("Global error:", err);
  res.status(500).json({ error: err.message || "Server error" });
});

Benefits:

  • No repetitive try-catch.

  • Errors are logged consistently.

  • Central point of control for formatting responses.

Best Practice 3: Use Error Classes for Clarity

class NotFoundError extends Error {
  constructor(message) {
    super(message);
    this.name = "NotFoundError";
    this.statusCode = 404;
  }
}

app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError("User not found");
  res.json(user);
}));

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({ error: err.message });
});

Now you can distinguish between client errors (404, 400) and server errors (500) easily.

Best Practice 4: Use Logging Libraries

Instead of console.log, use Winston, Pino, or Morgan for structured logs.

const winston = require('winston');
const logger = winston.createLogger({
  transports: [new winston.transports.Console()],
});

app.use((err, req, res, next) => {
  logger.error(err); // better than console.log
  res.status(500).json({ error: "Server error" });
});

Best Practice 5: Frontend (React) Error Boundaries

On the React side, try-catch is limited in async code. For rendering errors:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error("React Error Boundary:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children; 
  }
}

Why: Prevents your app from “white-screening” when a component throws.

Best Practice 6: Use Promise.catch Properly

When using async/await, errors outside try-catch can still be caught with .catch().

someAsyncFunction()
  .then(result => console.log(result))
  .catch(err => console.error("Async error:", err));

Summary

try-catch is a tool, not a silver bullet. Used poorly, it swallows errors and makes debugging a nightmare.

Key takeaways:

  • Don’t swallow errors — always log stack traces.

  • Centralize error handling in Express.

  • Use custom error classes for clarity.

  • Add error boundaries in React.

  • Adopt logging libraries for structured logs.

With these practices, you’ll spend less time wondering “Why is my app failing silently?” and more time actually fixing the issue.