The secret patterns that separate junior developers from senior ones
Stop Writing Bad Express.js Code! π±
Let's be honest: Most Express.js tutorials teach you the basics, then leave you hanging. You end up with spaghetti code that works... until it doesn't.
After reviewing 1000+ Express.js codebases, I've discovered the 10 patterns that elite backend developers use religiously. These aren't just "nice to have" techniquesβthey're the difference between code that scales and code that crashes at 3 AM.
Ready to level up? Let's dive in! π
π― Pattern #1: The Async Error Handler Pattern
β The Rookie Mistake:
app.get('/users', async (req, res) => {
const users = await User.find(); // What if this fails? π₯
res.json(users);
});
Problem: One database error = app crash!
β The Pro Solution:
// Create the magic wrapper
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Use it everywhere
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));
// Global error handler
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ error: 'Something went wrong!' });
});
π Why it's genius:
- Never crashes your app again
- Clean, readable async code
- Centralized error handling
- Senior dev approved β¨
π‘οΈ Pattern #2: The Validation Middleware Stack
β Messy validation:
app.post('/users', (req, res) => {
if (!req.body.email) {
return res.status(400).json({ error: 'Email required' });
}
if (!req.body.password || req.body.password.length < 8) {
return res.status(400).json({ error: 'Password too short' });
}
// 20 more lines of validation... π΅
});
β The Clean Pattern:
import { body, validationResult } from 'express-validator';
// Reusable validation rules
const userValidation = [
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
body('name').trim().isLength({ min: 2, max: 50 })
];
// Validation middleware
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array()
});
}
next();
};
// Clean route
app.post('/users', userValidation, validate, asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
}));
πͺ Power benefits:
- Reusable validation rules
- Automatic error messages
- Type-safe inputs
- Professional API responses
π Pattern #3: The JWT Authentication Middleware
β Copy-paste authentication:
app.get('/profile', (req, res) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'No token' });
jwt.verify(token, secret, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Invalid token' });
// Copy this everywhere? No thanks! π€
});
});
β The Middleware Magic:
import jwt from 'jsonwebtoken';
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = user; // Magic happens here! β¨
next();
});
};
// Use anywhere
app.get('/profile', authenticateToken, (req, res) => {
res.json({ user: req.user }); // User already available!
});
app.delete('/posts/:id', authenticateToken, asyncHandler(async (req, res) => {
const post = await Post.findByIdAndDelete(req.params.id, req.user.id);
res.json({ message: 'Post deleted' });
}));
π― Why developers love it:
- Write once, use everywhere
- Consistent security
- Clean separation of concerns
- Easy to test and maintain
π Pattern #4: The Response Formatter Pattern
β Inconsistent API responses:
app.get('/users', (req, res) => {
res.json(users); // Sometimes array
});
app.get('/user/:id', (req, res) => {
res.json({ user, message: 'Success' }); // Sometimes object
});
app.post('/users', (req, res) => {
res.status(201).json({ data: user, success: true }); // Sometimes different
});
Result: Frontend developers hate you π‘
β The Professional Standard:
// Response formatter utility
const sendResponse = (res, statusCode, data, message = null) => {
const response = {
success: statusCode < 400,
message,
data,
timestamp: new Date().toISOString()
};
res.status(statusCode).json(response);
};
// Attach to response object
app.use((req, res, next) => {
res.sendResponse = (statusCode, data, message) =>
sendResponse(res, statusCode, data, message);
next();
});
// Consistent usage
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.sendResponse(200, users, 'Users retrieved successfully');
}));
app.post('/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.sendResponse(201, user, 'User created successfully');
}));
π Consistent response format:
{
"success": true,
"message": "Users retrieved successfully",
"data": [...],
"timestamp": "2025-08-12T10:30:00.000Z"
}
Frontend developers now love you! β€οΈ
β‘ Pattern #5: The Database Connection Pattern
β Connection chaos:
// Connecting everywhere... bad idea!
app.get('/users', async (req, res) => {
const db = await mongoose.connect(DB_URL); // New connection every time!
const users = await User.find();
res.json(users);
});
β The Smart Singleton:
// db.js - One connection to rule them all
import mongoose from 'mongoose';
class Database {
constructor() {
this.connection = null;
}
async connect() {
if (this.connection) return this.connection;
try {
this.connection = await mongoose.connect(process.env.DB_URL, {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: 10, // Connection pooling
bufferMaxEntries: 0
});
console.log('π’ Database connected successfully');
return this.connection;
} catch (error) {
console.error('π΄ Database connection failed:', error);
process.exit(1);
}
}
async disconnect() {
if (this.connection) {
await mongoose.disconnect();
this.connection = null;
}
}
}
export default new Database();
// app.js
import database from './db.js';
const startServer = async () => {
await database.connect();
app.listen(PORT, () => {
console.log(`π Server running on port ${PORT}`);
});
};
startServer();
πͺ Performance boost:
- Single connection instance
- Connection pooling
- Graceful error handling
- Memory efficient
π Pattern #6: The Request Logger Pattern
β Flying blind:
// No idea what's happening in your API
app.get('/users', (req, res) => {
res.json(users);
});
β The Insight Machine:
import morgan from 'morgan';
// Custom logger format
const loggerFormat = ':method :url :status :response-time ms - :res[content-length]';
// Development logger
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// Production logger
if (process.env.NODE_ENV === 'production') {
app.use(morgan('combined'));
}
// Custom request tracker
app.use((req, res, next) => {
req.requestTime = new Date().toISOString();
req.requestId = Math.random().toString(36).substr(2, 9);
console.log(`π₯ [${req.requestId}] ${req.method} ${req.path} - ${req.requestTime}`);
next();
});
// Response tracker
app.use((req, res, next) => {
const originalSend = res.send;
res.send = function(data) {
console.log(`π€ [${req.requestId}] Response sent - ${res.statusCode}`);
originalSend.call(this, data);
};
next();
});
π What you get:
- Track every request
- Monitor response times
- Debug production issues
- Performance insights
π― Pattern #7: The Rate Limiting Pattern (The One You're Missing!)
π¨ Why 90% of developers skip this:
They think rate limiting is "advanced" or only for big companies. Wrong!
Even small APIs get attacked. One script kid can bring down your server.
β The Protection Shield:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Different limits for different endpoints
const createLimiter = (windowMs, max, message) => rateLimit({
store: new RedisStore({
client: redis,
}),
windowMs,
max,
message: { error: message },
standardHeaders: true,
legacyHeaders: false,
});
// Apply different rates
app.use('/api/auth', createLimiter(
15 * 60 * 1000, // 15 minutes
5, // 5 attempts
'Too many login attempts, try again later'
));
app.use('/api/users', createLimiter(
15 * 60 * 1000, // 15 minutes
100, // 100 requests
'Too many requests, slow down!'
));
app.use('/api/', createLimiter(
15 * 60 * 1000, // 15 minutes
1000, // 1000 requests
'API rate limit exceeded'
));
π‘οΈ Why it's critical:
- Prevents DDoS attacks
- Protects your database
- Saves server costs
- Professional API standard
π Pattern #8: The Graceful Shutdown Pattern
β The server killer:
app.listen(3000, () => {
console.log('Server running');
});
// Ctrl+C = Instant death! Users lose data π
β The Graceful Exit:
const server = app.listen(PORT, () => {
console.log(`π Server running on port ${PORT}`);
});
// Graceful shutdown handler
const gracefulShutdown = (signal) => {
console.log(`π‘ Received ${signal}. Shutting down gracefully...`);
server.close((err) => {
if (err) {
console.error('β Error during server close:', err);
process.exit(1);
}
console.log('β
Server closed successfully');
// Close database connections
mongoose.connection.close(() => {
console.log('β
Database connection closed');
process.exit(0);
});
});
// Force close after 30 seconds
setTimeout(() => {
console.error('β° Forced shutdown after timeout');
process.exit(1);
}, 30000);
};
// Listen for shutdown signals
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('π₯ Uncaught Exception:', err);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('π₯ Unhandled Rejection:', reason);
gracefulShutdown('unhandledRejection');
});
π― Production benefits:
- No lost user data
- Proper connection cleanup
- Container orchestration friendly
- Zero downtime deployments
ποΈ Pattern #9: The Controller Pattern
β Fat routes nightmare:
app.post('/users', async (req, res) => {
// 50 lines of business logic here
const { email, password, name } = req.body;
// Validation logic
if (!email || !password) {
return res.status(400).json({ error: 'Missing fields' });
}
// Business logic
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({ error: 'User exists' });
}
// Password hashing
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Database operations
const user = new User({
email,
password: hashedPassword,
name,
createdAt: new Date()
});
await user.save();
// Response logic
res.status(201).json({
message: 'User created',
user: { id: user._id, email: user.email, name: user.name }
});
});
Problem: Routes become unreadable monsters! πΉ
β The Clean Architecture:
// controllers/userController.js
class UserController {
static async createUser(req, res) {
try {
const userData = req.body;
const user = await UserService.createUser(userData);
res.sendResponse(201, user, 'User created successfully');
} catch (error) {
if (error.code === 'USER_EXISTS') {
return res.sendResponse(409, null, error.message);
}
throw error; // Let global handler catch it
}
}
static async getUsers(req, res) {
const { page = 1, limit = 10 } = req.query;
const users = await UserService.getUsers(page, limit);
res.sendResponse(200, users, 'Users retrieved successfully');
}
static async getUserById(req, res) {
const user = await UserService.getUserById(req.params.id);
if (!user) {
return res.sendResponse(404, null, 'User not found');
}
res.sendResponse(200, user, 'User retrieved successfully');
}
}
// services/userService.js
class UserService {
static async createUser(userData) {
const { email, password, name } = userData;
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new AppError('User already exists', 'USER_EXISTS');
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = await User.create({
email,
password: hashedPassword,
name
});
// Return without password
return {
id: user._id,
email: user.email,
name: user.name,
createdAt: user.createdAt
};
}
static async getUsers(page, limit) {
const skip = (page - 1) * limit;
const users = await User.find({})
.select('-password')
.skip(skip)
.limit(limit)
.sort({ createdAt: -1 });
const total = await User.countDocuments();
return {
users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
}
// routes/userRoutes.js
import { UserController } from '../controllers/userController.js';
const router = express.Router();
router.post('/', userValidation, validate, asyncHandler(UserController.createUser));
router.get('/', asyncHandler(UserController.getUsers));
router.get('/:id', asyncHandler(UserController.getUserById));
export default router;
// app.js
import userRoutes from './routes/userRoutes.js';
app.use('/api/users', userRoutes);
π― Architecture benefits:
- Separation of concerns
- Testable components
- Reusable business logic
- Maintainable codebase
π¦ Pattern #10: The Environment Configuration Pattern
β Config chaos:
const dbUrl = 'mongodb://localhost:27017/myapp'; // Hardcoded!
const jwtSecret = 'supersecret123'; // In source code!
const port = 3000; // Never changes!
Problem: Different environments need different configs!
β The Pro Configuration:
// config/index.js
import dotenv from 'dotenv';
import path from 'path';
// Load environment-specific .env file
const envFile = process.env.NODE_ENV === 'production'
? '.env.production'
: process.env.NODE_ENV === 'test'
? '.env.test'
: '.env.development';
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
const config = {
// Server configuration
server: {
port: parseInt(process.env.PORT) || 3000,
env: process.env.NODE_ENV || 'development',
cors: {
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
credentials: true
}
},
// Database configuration
database: {
url: process.env.DB_URL || 'mongodb://localhost:27017/myapp',
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: parseInt(process.env.DB_POOL_SIZE) || 10
}
},
// JWT configuration
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d'
},
// Redis configuration
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
options: {
retryDelayOnFailover: 100,
maxRetriesPerRequest: 3
}
},
// Email configuration
email: {
service: process.env.EMAIL_SERVICE || 'gmail',
user: process.env.EMAIL_USER,
password: process.env.EMAIL_PASSWORD
},
// Rate limiting
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX) || 100
}
};
// Validate required environment variables
const requiredEnvVars = ['DB_URL', 'JWT_SECRET'];
if (config.server.env === 'production') {
requiredEnvVars.push('EMAIL_USER', 'EMAIL_PASSWORD', 'REDIS_URL');
}
const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]);
if (missingEnvVars.length > 0) {
console.error('β Missing required environment variables:', missingEnvVars);
process.exit(1);
}
export default config;
// Usage in your app
import config from './config/index.js';
app.listen(config.server.port, () => {
console.log(`π Server running on port ${config.server.port}`);
});
Environment files:
# .env.development
NODE_ENV=development
PORT=3000
DB_URL=mongodb://localhost:27017/myapp_dev
JWT_SECRET=dev_secret_key
REDIS_URL=redis://localhost:6379
# .env.production
NODE_ENV=production
PORT=8080
DB_URL=mongodb://prod-cluster/myapp
JWT_SECRET=super_secure_production_secret
REDIS_URL=redis://prod-redis:6379
RATE_LIMIT_MAX=50
π― Configuration benefits:
- Environment-specific settings
- Secure secret management
- Easy deployment
- Development flexibility
π Putting It All Together: The Ultimate Express.js App
Here's how a senior developer combines all these patterns:
// app.js - The masterpiece
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import config from './config/index.js';
import database from './config/database.js';
import { asyncHandler } from './middleware/asyncHandler.js';
import { authenticateToken } from './middleware/auth.js';
import { rateLimiters } from './middleware/rateLimit.js';
import { requestLogger } from './middleware/logger.js';
import { responseFormatter } from './middleware/responseFormatter.js';
// Routes
import userRoutes from './routes/userRoutes.js';
import authRoutes from './routes/authRoutes.js';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors(config.server.cors));
app.use(compression());
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Custom middleware
app.use(requestLogger);
app.use(responseFormatter);
// Rate limiting
app.use('/api/auth', rateLimiters.auth);
app.use('/api/', rateLimiters.general);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', authenticateToken, userRoutes);
// Health check
app.get('/health', (req, res) => {
res.sendResponse(200, { status: 'OK', timestamp: new Date().toISOString() });
});
// 404 handler
app.use('*', (req, res) => {
res.sendResponse(404, null, 'Route not found');
});
// Global error handler
app.use((error, req, res, next) => {
console.error(`β Error in ${req.method} ${req.path}:`, error);
const statusCode = error.statusCode || 500;
const message = config.server.env === 'production'
? 'Internal server error'
: error.message;
res.sendResponse(statusCode, null, message);
});
// Start server with graceful shutdown
const startServer = async () => {
try {
await database.connect();
const server = app.listen(config.server.port, () => {
console.log(`π Server running on port ${config.server.port}`);
console.log(`π Environment: ${config.server.env}`);
});
// Graceful shutdown setup
setupGracefulShutdown(server);
} catch (error) {
console.error('β Failed to start server:', error);
process.exit(1);
}
};
startServer();
π― Your Action Plan: Implement These Today!
Week 1: Foundation Patterns
- β Pattern #1: Async Error Handler
- β Pattern #2: Validation Middleware
- β Pattern #10: Environment Configuration
Week 2: Security & Performance
- β Pattern #3: JWT Authentication
- β Pattern #7: Rate Limiting
- β Pattern #8: Graceful Shutdown
Week 3: Professional Polish
- β Pattern #4: Response Formatter
- β Pattern #6: Request Logger
- β Pattern #9: Controller Pattern
Week 4: Optimization
- β Pattern #5: Database Connection
- π Celebrate: You're now an Express.js expert!
π‘ Pro Tips from the Trenches
π₯ Hot Tip #1: Start with Pattern #1
The async error handler will save you more debugging time than any other pattern.
π₯ Hot Tip #2: Don't Skip Pattern #7
Rate limiting isn't optional in 2025. Even small apps get attacked.
π₯ Hot Tip #3: Pattern #4 is a Game Changer
Consistent API responses make frontend developers love working with your API.
π₯ Hot Tip #4: Combine Patterns for Maximum Power
Use validation + authentication + rate limiting together for bulletproof routes.
π The Bottom Line
Junior developers write code that works.
Senior developers write code that scales, performs, and never breaks at 3 AM.
These 10 patterns are your pathway from junior to senior. They're not just "best practices"βthey're battle-tested solutions that handle real-world production challenges.
The best part? You can implement them one at a time. Start with the async error handler today, and your Express.js apps will immediately become more reliable.
π What's Next?
Pick one pattern from this article. Implement it in your current project. See the difference it makes.
Which pattern surprised you the most? Drop a comment below and let's discuss! π
Found this helpful? Share it with your teamβthey'll thank you later! β¨
P.S. Senior developers aren't born knowing these patterns. They learned them the hard way (production outages at 3 AM). You just got the shortcut! π
Top comments (0)