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.

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.
- Create the directory
src/utils/. - Create the file
src/utils/AppError.js. - 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.
-
Test Operational Error (Production):
- Run
npm startto start the server in production mode. - In Postman, make a
GETrequest to an endpoint for a non-existent ID (e.g.,http://localhost:5001/api/items/60c72b2f5f1b2c001f8e8b8b). - Expected: You should get a
404 Not Foundstatus with a clean JSON body like{"success": false, "message": "No item found with that ID"}. There should be no stack trace.
- Run
-
Test Programming Error (Development):
- Stop the server.
- Temporarily add a line of code that will cause a bug, like
const x = y;, inside yourGET allroute handler. - Run
npm run devto start in development mode. - Make a
GETrequest to fetch all items. - Expected: You should get a
500 Internal Server Errorstatus, and the JSON response body must include the full stack trace, pointing to theReferenceError: y is not defined.
-
Test Programming Error (Production):
- Keep the bug in your code.
- Stop the server and run
npm startfor production mode. - Make the same
GETrequest to fetch all items. - Expected: You should get a
500 Internal Server Errorstatus 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_ENVenvironment variable is the standard way to control this behavior. - The
isOperationalproperty on a customAppErrorclass 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.