Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 20

Global Error Handling for Secure JSON Responses

Recommended resources

Introduction

In our last session, you did an excellent job building an asyncHandler utility. This was a crucial step to ensure that any errors occurring in your asynchronous route handlers are reliably caught and forwarded to your global error handler. Right now, our error pipeline looks like this: async error -> asyncHandler -> next(err) -> globalErrorHandler.

However, our global error handler is still quite basic. It sends the same type of error response for every situation. This creates a major security vulnerability in a production environment by potentially leaking internal server details, like stack traces.

Take a look at the image below. This is exactly what we need to prevent.

Demonstration of Stack Trace Leakage in Development Environment
This terminal output shows how a default Node.js error response can leak a full stack trace to the client, revealing sensitive information about the server's internal workings.

In this lesson, you will upgrade your global error handler to be "environment-aware." You will implement the professional standard for error handling:

  • In development, you'll send detailed error messages, including the stack trace, to make debugging easy.
  • In production, you'll send clean, generic JSON responses that give the user just enough information without exposing any internal server details.

This will make your API more secure and robust, bringing it one step closer to production-readiness for your exam.


1. Development vs. Production Errors

The core principle is to tailor the error response to the environment, which is determined by the NODE_ENV environment variable.

  • NODE_ENV=development: For when you are coding and debugging. You want as much information as possible.
  • NODE_ENV=production: For when the application is live. Security and a clean user experience are the priorities.

Let's watch a short video that clearly explains this distinction and shows how to implement the conditional logic in the error handler.

#95 Development & Production Error | Error Handling in Express | A Complete NODE JS Course

This video from procademy demonstrates the standard pattern for creating environment-specific error responses in Express.

Watch the segment from 00:00 to 03:21. Focus on how process.env.NODE_ENV is used in an if statement to decide what information to include in the JSON response. Notice that the development response includes the full error object and the error.stack.


2. Implementing an Environment-Aware Handler

Now, let's apply this pattern. First, we need to be able to set the NODE_ENV variable when we run our application.

Action: Update package.json

Modify the scripts section of your package.json to explicitly set NODE_ENV. This ensures our application knows which environment it's running in.

// package.json

"scripts": {
  "start": "NODE_ENV=production node src/server.js",
  "dev": "NODE_ENV=development nodemon src/server.js"
},

Now, running npm run dev will set NODE_ENV to development, and npm start will set it to production.

Action: Update the Global Error Handler

Next, replace the entire content of your src/middleware/errorHandler.js file with the following code. This new version implements the logic you saw in the video.

// src/middleware/errorHandler.js

const errorHandler = (err, req, res, next) => {
  let statusCode = err.statusCode || 500;
  let message = err.message || 'Internal Server Error';

  // For development, send detailed error
  if (process.env.NODE_ENV === 'development') {
    console.error('ERROR 💥', err);
    return res.status(statusCode).json({
      success: false,
      message,
      stack: err.stack,
      error: err, // Send the full error object for detailed debugging
    });
  }

  // For production, send clean error
  res.status(statusCode).json({
    success: false,
    message,
  });
};

module.exports = errorHandler;

With this in place, you will now get detailed stack traces when you run npm run dev but not when you run npm start.


3. Distinguishing Operational vs. Programming Errors

We've stopped leaking stack traces in production, but we can do better. Some errors are "operational"—they are expected parts of the application's flow (e.g., "User not found," "Invalid input"). For these, we want to send a specific, helpful message to the client. Other errors are "programming errors"—unexpected bugs in our code. For these, we should send a generic "Something went wrong" message to avoid revealing any implementation details.

To handle this, we will create a custom AppError class and update our production error logic.

For a clear, concise guide on this pattern, please read the following sections from an excellent article by JSMastery.

Error Handling in Express Without try/catch Hell

This article provides the exact code and explanation for creating a custom AppError class and using it to differentiate between error types.

Read the sections titled 'Level Up: The AppError Class' and the updated 'Global Error Handler' that follows it. Focus on: The structure of the AppError class, especially the statusCode and isOperational properties. How the global error handler is modified to check err.isOperational before deciding which message to send in production.

Action: Create the AppError Utility

Based on the article, create a new utils directory inside src/ and add the AppError.js file.

  1. Create the directory src/utils/.
  2. Create the file src/utils/AppError.js.
  3. Add the following code:
// src/utils/AppError.js

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);

    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true; // To distinguish from programming errors

    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

Action: Upgrade the Production Error Handler

Now, let's update src/middleware/errorHandler.js one last time to use this new logic. Note the change in the else block for production.

// src/middleware/errorHandler.js

const AppError = require('../utils/AppError');

const errorHandler = (err, req, res, next) => {
  let error = { ...err };
  error.message = err.message;
  error.statusCode = err.statusCode || 500;

  // Development: send detailed error
  if (process.env.NODE_ENV === 'development') {
    console.error('ERROR 💥', error);
    return res.status(error.statusCode).json({
      success: false,
      message: error.message,
      stack: err.stack,
      error: err,
    });
  }

  // Production: send clean, user-friendly error
  if (error.isOperational) {
    // Operational, trusted error: send message to client
    return res.status(error.statusCode).json({
      success: false,
      message: error.message,
    });
  } 
  
  // Programming or other unknown error: don't leak error details
  // 1) Log error for developer
  console.error('ERROR 💥', error);
  // 2) Send generic message
  return res.status(500).json({
    success: false,
    message: 'Something went very wrong!',
  });
};

module.exports = errorHandler;

4. Refactoring Routes to Use AppError

Your error handler is now ready to distinguish between error types. The final step is to use AppError in your routes for expected error conditions, like when a requested resource is not found.

Action: Update Your Routes File

Open your main routes file (e.g., src/routes/item.routes.js). Import AppError and refactor your "not found" checks to throw a new AppError instead of sending a response directly. The asyncHandler will catch it and pass it to your global handler.

Your GET by ID, PUT, and DELETE routes should be changed from this:

// Before
router.get('/:id', asyncHandler(async (req, res, next) => {
  const item = await Item.findById(req.params.id);
  if (!item) {
    // This is what we're changing
    return res.status(404).json({ success: false, message: 'Item not found' });
  }
  res.status(200).json({ success: true, data: item });
}));

To this:

// After
const AppError = require('../utils/AppError'); // 1. Import AppError

router.get('/:id', asyncHandler(async (req, res, next) => {
  const item = await Item.findById(req.params.id);
  if (!item) {
    // 2. Throw a new AppError
    return next(new AppError('No item found with that ID', 404));
  }
  res.status(200).json({ success: true, data: item });
}));

Important: Apply this throw new AppError(...) pattern to all places where a resource might not be found (GET /:id, PUT /:id, DELETE /:id). This centralizes your response logic in the errorHandler.


5. Verification

Let's test our new, robust error handling system.

  1. Test Operational Error (Production):

    • Run npm start to start the server in production mode.
    • In Postman, make a GET request to an endpoint for a non-existent ID (e.g., http://localhost:5001/api/items/60c72b2f5f1b2c001f8e8b8b).
    • Expected: You should get a 404 Not Found status with a clean JSON body like {"success": false, "message": "No item found with that ID"}. There should be no stack trace.
  2. Test Programming Error (Development):

    • Stop the server.
    • Temporarily add a line of code that will cause a bug, like const x = y;, inside your GET all route handler.
    • Run npm run dev to start in development mode.
    • Make a GET request to fetch all items.
    • Expected: You should get a 500 Internal Server Error status, and the JSON response body must include the full stack trace, pointing to the ReferenceError: y is not defined.
  3. Test Programming Error (Production):

    • Keep the bug in your code.
    • Stop the server and run npm start for production mode.
    • Make the same GET request to fetch all items.
    • Expected: You should get a 500 Internal Server Error status with the generic message {"success": false, "message": "Something went very wrong!"}. There must be no stack trace.

Once you've confirmed all three cases work as expected, remember to remove the buggy line of code you added for testing.


Conclusion

Congratulations! You have implemented a professional, production-grade error handling system. This is a critical skill for any backend developer and a key requirement for your certification exam.

Key Takeaways:

  • Error responses must be different for development (detailed) and production (secure and clean).
  • The NODE_ENV environment variable is the standard way to control this behavior.
  • The isOperational property on a custom AppError class allows you to distinguish between expected errors (like "Not Found") and unexpected programming bugs.
  • In production, you should log programming errors for debugging but only send a generic message to the client to prevent leaking sensitive information.

Next Up:

Your API is now functionally complete and robustly error-handled. In the next lesson, we will shift our focus to documentation. You will install and configure Swagger to generate live, interactive API docs, making your API easy for others (and your exam evaluator) to understand and use.