I've built backend APIs for ERP systems, e-commerce platforms, and various production applications. Along the way, I've made plenty of mistakes and learned what actually matters when building Node.js backends.
The API That Broke Under Load
At Megaverse Technologies, I built an ERP system that worked perfectly in development. But when we deployed it and 50+ users started using it simultaneously, everything fell apart.
The database was getting hammered with queries. API responses were timing out. Users were frustrated. I spent weeks fixing issues that should have been prevented from the start.
Error Handling: Your First Line of Defense
The biggest mistake I made? Poor error handling. When something went wrong, users saw cryptic error messages or the app just crashed.
What I do now:
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error('Error:', err);
// Don't expose internal errors to users
const statusCode = err.statusCode || 500;
const message = err.statusCode
? err.message
: 'Something went wrong. Please try again.';
res.status(statusCode).json({
success: false,
error: {
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};I also create custom error classes for different scenarios:
class ValidationError extends Error {
constructor(message) {
super(message);
this.statusCode = 400;
this.name = 'ValidationError';
}
}
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.statusCode = 404;
this.name = 'NotFoundError';
}
}This makes error handling consistent and predictable.
Input Validation: Trust Nothing
I learned this the hard way. A user submitted a form with SQL injection attempts. Luckily, we were using parameterized queries, but it made me realize how vulnerable we were.
Always validate input:
const { body, validationResult } = require('express-validator');
const validateUser = [
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }).matches(/[A-Z]/).matches(/[0-9]/),
body('name').trim().isLength({ min: 2, max: 50 }),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
next();
}
];Never trust user input. Ever. Validate everything, sanitize where needed, and always use parameterized queries.
Database Queries: The Performance Killer
In that ERP system, I was making N+1 queries everywhere. Loading a user's dashboard made 50+ database calls. It was slow and expensive.
The solution: Batch queries and use proper joins:
// Bad: N+1 queries
const users = await User.find();
for (const user of users) {
user.profile = await Profile.findOne({ userId: user._id });
user.orders = await Order.find({ userId: user._id });
}
// Good: Single query with aggregation
const users = await User.aggregate([
{
$lookup: {
from: 'profiles',
localField: '_id',
foreignField: 'userId',
as: 'profile'
}
},
{
$lookup: {
from: 'orders',
localField: '_id',
foreignField: 'userId',
as: 'orders'
}
}
]);I also learned to use database indexes properly. A query that took 2 seconds now takes 50ms—just by adding the right index.
Connection Pooling: Don't Ignore It
Early on, I didn't understand connection pooling. Every API request created a new database connection. Under load, we ran out of connections.
Proper connection pooling:
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Maximum connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Use the pool for queries
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);This prevents connection exhaustion and improves performance.
Authentication: Get It Right
I've seen authentication implemented wrong so many times. Storing passwords in plain text, weak JWT secrets, no token expiration.
What I do:
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
// Hash passwords
const hashPassword = async (password) => {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
};
// Generate tokens with expiration
const generateToken = (userId) => {
return jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
};
// Verify tokens
const verifyToken = (token) => {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
throw new Error('Invalid or expired token');
}
};Always use HTTPS in production. Always hash passwords. Always set token expiration. These aren't optional.
API Response Format: Consistency Matters
I used to return different response formats for different endpoints. Sometimes success data was at the root, sometimes nested. It made frontend development frustrating.
Standardize your responses:
// Success response
{
success: true,
data: { ... },
message: "Operation completed successfully"
}
// Error response
{
success: false,
error: {
code: "VALIDATION_ERROR",
message: "Email is required"
}
}
// Paginated response
{
success: true,
data: [...],
pagination: {
page: 1,
limit: 20,
total: 100,
pages: 5
}
}Consistency makes frontend integration easier and reduces bugs.
Logging: Your Debugging Lifeline
When something breaks in production, good logs are invaluable. I learned this after spending hours trying to debug an issue with no logs.
Structured logging:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Log important events
logger.info('User logged in', { userId: user.id, ip: req.ip });
logger.error('Database query failed', { error: err.message, query: query });Good logs help you understand what happened and why. Don't skip them.
What I Wish I Knew Earlier
1. Start with error handling. It's easier to add features when errors are handled properly.
2. Validate everything. User input is your biggest security risk.
3. Profile your queries. Don't guess what's slow—measure it.
4. Use connection pooling. It prevents so many issues.
5. Log everything important. You'll thank yourself later.
The Bottom Line
Building backend APIs isn't just about making them work—it's about making them reliable, secure, and maintainable. The practices I've shared here aren't theoretical—they're things I've learned from real production issues.
Start with the basics: error handling, validation, and proper database usage. Get those right, and everything else becomes easier.