Node.js Best Practices

Essential guidelines for building secure, performant, and maintainable Node.js applications

About This Guide

This guide covers essential best practices for Node.js development, from security and error handling to performance optimization and production deployment. These patterns are based on real-world experience and industry standards.

Whether you're building REST APIs, microservices, or full-stack applications, following these practices will help you write better, more maintainable Node.js code.

Project Structure

Organize your codebase

A well-organized project structure makes code maintainable and scalable.

Recommended Structure ✓ GOOD
my-app/
├── src/
│   ├── config/           # Configuration files
│   │   └── database.js
│   ├── controllers/      # Route controllers
│   │   └── userController.js
│   ├── middleware/       # Custom middleware
│   │   ├── auth.js
│   │   └── errorHandler.js
│   ├── models/           # Data models
│   │   └── User.js
│   ├── routes/           # API routes
│   │   └── userRoutes.js
│   ├── services/         # Business logic
│   │   └── userService.js
│   ├── utils/            # Utility functions
│   │   └── logger.js
│   └── app.js            # Express app setup
├── tests/                # Test files
│   ├── unit/
│   └── integration/
├── .env.example          # Environment variables template
├── .gitignore
├── package.json
└── server.js             # Entry point

Structure Benefits

  • Separation of concerns - Each folder has a clear purpose
  • Scalability - Easy to add new features without clutter
  • Testability - Business logic separated from routing
  • Team collaboration - Developers know where to find code

Error Handling

Handle errors gracefully

Don't: Swallow Errors

try {
  await doSomething();
} catch (err) {
  // Silent failure - BAD!
}

Do: Handle Properly

try {
  await doSomething();
} catch (err) {
  logger.error('Failed:', err);
  throw err; // or handle appropriately
}
Centralized Error Handler ✓ RECOMMENDED
// middleware/errorHandler.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    
    Error.captureStackTrace(this, this.constructor);
  }
}

const errorHandler = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';

  if (process.env.NODE_ENV === 'development') {
    res.status(err.statusCode).json({
      status: err.status,
      error: err,
      message: err.message,
      stack: err.stack,
    });
  } else {
    // Production: Don't leak error details
    res.status(err.statusCode).json({
      status: err.status,
      message: err.isOperational ? err.message : 'Something went wrong',
    });
  }
};

// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
  console.error('UNHANDLED REJECTION! 💥', err);
  process.exit(1);
});

module.exports = { AppError, errorHandler };
Async Error Wrapper
// utils/catchAsync.js - Eliminates try/catch in every route
const catchAsync = (fn) => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

// Usage in controller
const getUser = catchAsync(async (req, res, next) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    return next(new AppError('User not found', 404));
  }
  
  res.status(200).json({ data: user });
});

Security Best Practices

Protect your application

Critical Security Measures

  • Never commit secrets (.env) to version control
  • Always validate and sanitize user input
  • Use HTTPS in production
  • Keep dependencies updated (npm audit)
  • Implement rate limiting to prevent abuse
Security Setup
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');

const app = express();

// Set security HTTP headers
app.use(helmet());

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP',
});
app.use('/api', limiter);

// Body parser with size limit
app.use(express.json({ limit: '10kb' }));

// Data sanitization against NoSQL injection
app.use(mongoSanitize());

// Data sanitization against XSS
app.use(xss());

// Prevent parameter pollution
app.use(hpp());
Environment Variables
// config/env.js
require('dotenv').config();

const requiredEnvVars = [
  'NODE_ENV',
  'PORT',
  'DATABASE_URL',
  'JWT_SECRET',
];

requiredEnvVars.forEach((varName) => {
  if (!process.env[varName]) {
    throw new Error(`Missing required environment variable: ${varName}`);
  }
});

module.exports = {
  nodeEnv: process.env.NODE_ENV,
  port: process.env.PORT || 3000,
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
};

Async/Await Patterns

Modern asynchronous code

Don't: Callback Hell

getData((err, data) => {
  if (err) return handleError(err);
  processData(data, (err, result) => {
    if (err) return handleError(err);
    saveResult(result, (err) => {
      // Nested callbacks...
    });
  });
});

Do: Use Async/Await

try {
  const data = await getData();
  const result = await processData(data);
  await saveResult(result);
} catch (err) {
  handleError(err);
}
Parallel Async Operations
// ❌ Sequential - Slow (takes 3 seconds total)
const user = await User.findById(id);
const posts = await Post.find({ userId: id });
const comments = await Comment.find({ userId: id });

// ✅ Parallel - Fast (takes 1 second total)
const [user, posts, comments] = await Promise.all([
  User.findById(id),
  Post.find({ userId: id }),
  Comment.find({ userId: id }),
]);

// Handle errors in parallel operations
const results = await Promise.allSettled([
  operation1(),
  operation2(),
  operation3(),
]);

results.forEach((result, index) => {
  if (result.status === 'rejected') {
    console.error(`Operation ${index} failed:`, result.reason);
  }
});

Performance Optimization

Make your app faster
Response Compression
const compression = require('compression');

// Compress all responses
app.use(compression());

// Custom compression settings
app.use(compression({
  level: 6, // Compression level (0-9)
  threshold: 1024, // Only compress responses > 1KB
  filter: (req, res) => {
    if (req.headers['x-no-compression']) {
      return false;
    }
    return compression.filter(req, res);
  },
}));
Database Connection Pooling
const mongoose = require('mongoose');

// Configure connection pool
mongoose.connect(process.env.DATABASE_URL, {
  maxPoolSize: 10,           // Maximum connections
  minPoolSize: 2,            // Minimum connections
  serverSelectionTimeoutMS: 5000,
  socketTimeoutMS: 45000,
});

// Monitor connection events
mongoose.connection.on('connected', () => {
  console.log('MongoDB connected');
});

mongoose.connection.on('error', (err) => {
  console.error('MongoDB connection error:', err);
});
Caching with Redis
const redis = require('redis');
const client = redis.createClient();

// Cache middleware
const cache = (duration) => {
  return async (req, res, next) => {
    const key = `cache:${req.originalUrl}`;
    
    try {
      const cachedData = await client.get(key);
      
      if (cachedData) {
        return res.json(JSON.parse(cachedData));
      }
      
      // Store original res.json
      const originalJson = res.json.bind(res);
      
      res.json = (data) => {
        // Cache the response
        client.setEx(key, duration, JSON.stringify(data));
        return originalJson(data);
      };
      
      next();
    } catch (err) {
      next(err);
    }
  };
};

// Usage: Cache for 5 minutes
app.get('/api/posts', cache(300), getPostsController);

Performance Tips

• Use clustering to utilize all CPU cores
• Enable gzip compression for responses
• Implement database indexing for faster queries
• Use CDN for static assets
• Monitor with tools like PM2, New Relic, or Datadog

Logging

Proper application logging
Winston Logger Setup
const winston = require('winston');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'my-app' },
  transports: [
    // Write errors to error.log
    new winston.transports.File({ 
      filename: 'logs/error.log', 
      level: 'error' 
    }),
    // Write all logs to combined.log
    new winston.transports.File({ filename: 'logs/combined.log' }),
  ],
});

// Console logging in development
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple(),
  }));
}

// Usage
logger.info('Server started', { port: 3000 });
logger.error('Database error', { error: err.message });

module.exports = logger;

Testing

Write testable code
Jest Unit Test Example
// userService.test.js
const userService = require('../services/userService');
const User = require('../models/User');

// Mock the User model
jest.mock('../models/User');

describe('UserService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe('createUser', () => {
    it('should create a new user', async () => {
      const userData = {
        name: 'John Doe',
        email: 'john@example.com',
      };

      const mockUser = { id: 1, ...userData };
      User.create.mockResolvedValue(mockUser);

      const result = await userService.createUser(userData);

      expect(User.create).toHaveBeenCalledWith(userData);
      expect(result).toEqual(mockUser);
    });

    it('should throw error if email exists', async () => {
      User.create.mockRejectedValue(new Error('Email exists'));

      await expect(
        userService.createUser({ email: 'test@test.com' })
      ).rejects.toThrow('Email exists');
    });
  });
});

Testing Best Practices

  • Unit tests - Test individual functions/modules in isolation
  • Integration tests - Test how components work together
  • E2E tests - Test complete user workflows
  • Test coverage - Aim for 80%+ code coverage
  • Mocking - Mock external dependencies (DB, APIs)
  • CI/CD - Run tests automatically on every commit

Production Deployment

Deploy with confidence
Category Development Production
Error Details Show full stack traces Hide internal errors
Logging Console logs File/service logs (Winston)
Dependencies All (dev + prod) Production only
Caching Minimal/disabled Aggressive caching
Compression Optional Always enabled
HTTPS Optional Required
Process Manager Node directly PM2/Docker
PM2 Ecosystem File
// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-app',
    script: './server.js',
    instances: 'max',          // Use all CPU cores
    exec_mode: 'cluster',       // Cluster mode
    env: {
      NODE_ENV: 'development'
    },
    env_production: {
      NODE_ENV: 'production',
      PORT: 8080
    },
    error_file: './logs/err.log',
    out_file: './logs/out.log',
    log_date_format: 'YYYY-MM-DD HH:mm:ss',
    merge_logs: true,
    autorestart: true,
    max_memory_restart: '1G',
    watch: false
  }]
};

// Start: pm2 start ecosystem.config.js --env production

Production Checklist

✓ Set NODE_ENV=production
✓ Enable HTTPS with valid SSL certificate
✓ Use process manager (PM2/Docker)
✓ Enable logging to files/services
✓ Set up monitoring and alerts
✓ Configure automatic restarts
✓ Use environment variables for secrets
✓ Enable rate limiting and CORS
✓ Set up database backups
✓ Configure health check endpoints