Most Node.js APIs work fine in development. Then they go to production and break in ways nobody expected.
The problem is not Node.js. The problem is how the API was built. Development environments are forgiving. Production is not.
Here is how to build a REST API with Node.js that actually holds up when real users hit it.
Start With a Proper Project Structure
The most common mistake is dumping everything into a single server.js file. It works until it doesn't - and when it breaks, it is painful to debug.
A structure that scales:
/src
/routes - API route definitions
/controllers - Request handling logic
/services - Business logic
/middleware - Auth, validation, error handling
/models - Database schemas
/config - Environment configuration
server.js - Entry pointEach layer has one responsibility. Routes define endpoints. Controllers handle requests and responses. Services contain business logic. This separation makes your codebase easier to test, debug, and extend.
Handle Errors the Right Way
Most Node.js APIs handle errors reactively - something fails, an error gets thrown, and if you are lucky it gets caught somewhere. Under load, this falls apart quickly.
Build a centralised error handler from the start:
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
success: false,
error: message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
};
module.exports = errorHandler;Register it last in your Express middleware chain. Every unhandled error flows through it, giving you consistent error responses and a single place to log failures.
Also add a catch for unhandled promise rejections at the process level:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});Without this, a failed async operation can silently crash your server.
Validate Every Request
Never trust incoming data. Not from your own frontend, not from trusted clients - never.
Use a validation library like Joi or Zod at the controller level before any business logic runs:
const Joi = require('joi');
const createUserSchema = Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required()
});
const validateRequest = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
next();
};
};This keeps validation logic out of your controllers and makes it reusable across routes. Bad requests get rejected at the door - they never reach your database.
Add Rate Limiting
Without rate limiting, a single bad actor or a traffic spike can take your API down. The express-rate-limit package handles this cleanly:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per window
message: {
success: false,
error: 'Too many requests, please try again later.'
}
});
app.use('/api/', limiter);For authentication routes specifically, use a stricter limit - something like 5 requests per 15 minutes. Brute force attacks on login endpoints are extremely common.
Use Async/Await Correctly With Express
Express does not catch errors from async functions automatically. Without a wrapper, an async error will crash your process silently:
// This will crash without proper handling
app.get('/users', async (req, res) => {
const users = await User.find(); // if this throws, Express won't catch it
res.json(users);
});Wrap async route handlers to catch and forward errors:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Now this works safely
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json({ success: true, data: users });
}));This small wrapper eliminates an entire class of silent failures.
Use Environment Variables Properly
Hardcoded credentials in your codebase are a security incident waiting to happen. Use dotenv for local development and proper secrets management in production:
require('dotenv').config();
const config = {
port: process.env.PORT || 3000,
mongoUri: process.env.MONGO_URI,
jwtSecret: process.env.JWT_SECRET,
nodeEnv: process.env.NODE_ENV || 'development'
};
const required = ['MONGO_URI', 'JWT_SECRET'];
required.forEach(key => {
if (!process.env[key]) {
console.error(`Missing required environment variable: ${key}`);
process.exit(1);
}
});Failing fast on startup is far better than mysterious failures at runtime.
Add Logging
console.log is not a logging strategy. In production, you need structured logs that are searchable and include context:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}Structured JSON logs mean you can actually find what went wrong when something fails at 2am.
The Result
A Node.js REST API built this way handles real traffic:
- Errors are caught and handled centrally - no silent crashes
- Bad requests are rejected before hitting the database
- Rate limiting prevents abuse and traffic spikes
- Async errors are forwarded correctly to the error handler
- Logs give you visibility into what is happening
The difference between an API that works in development and one that works in production is almost always in the fundamentals, not the features.
At Velox Studio, every backend we build follows these patterns from day one - not as an afterthought.
What is the most common Node.js production issue you have encountered? Drop it in the comments.