DEV Community

Rakesh Bisht
Rakesh Bisht

Posted on

10 Express.js Patterns Every Backend Developer Should Know (You're Probably Missing #7!) πŸš€

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);
});
Enter fullscreen mode Exit fullscreen mode

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!' });
});
Enter fullscreen mode Exit fullscreen mode

πŸŽ‰ 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... 😡
});
Enter fullscreen mode Exit fullscreen mode

βœ… 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);
}));
Enter fullscreen mode Exit fullscreen mode

πŸ’ͺ 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! 😀
  });
});
Enter fullscreen mode Exit fullscreen mode

βœ… 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' });
}));
Enter fullscreen mode Exit fullscreen mode

🎯 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
});
Enter fullscreen mode Exit fullscreen mode

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');
}));
Enter fullscreen mode Exit fullscreen mode

πŸŽ‰ Consistent response format:

{
  "success": true,
  "message": "Users retrieved successfully",
  "data": [...],
  "timestamp": "2025-08-12T10:30:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

βœ… 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();
Enter fullscreen mode Exit fullscreen mode

πŸ’ͺ 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);
});
Enter fullscreen mode Exit fullscreen mode

βœ… 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();
});
Enter fullscreen mode Exit fullscreen mode

πŸ” 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'
));
Enter fullscreen mode Exit fullscreen mode

πŸ›‘οΈ 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 πŸ’€
Enter fullscreen mode Exit fullscreen mode

βœ… 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');
});
Enter fullscreen mode Exit fullscreen mode

🎯 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 }
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

🎯 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!
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🎯 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();
Enter fullscreen mode Exit fullscreen mode

🎯 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)