DEV Community

Cover image for Complete Guide to Node.js Best Practices for Production in 2024
Muhammad Arslan
Muhammad Arslan

Posted on • Originally published at muhammadarslan.codes

Complete Guide to Node.js Best Practices for Production in 2024

Building a Node.js app that works locally is easy. Building one that survives production — with real traffic, unpredictable errors, and zero-downtime deployments — is an entirely different game.

After 5+ years of shipping Node.js backends to production for enterprise clients globally, I've distilled the patterns that separate hobby projects from battle-tested systems. This is the guide I wish I had when I started.


1. Standardizing Project Architecture

The single biggest mistake I see in Node.js projects? A flat file structure where routes, business logic, and database calls are all tangled together in one file.

A scalable Node.js application should follow a strictly layered architecture:

src/
├── controllers/    # HTTP lifecycle — validate input, send responses
├── services/       # Business logic — the brain of your app
├── repositories/   # Data access — Mongoose, Prisma, Knex abstractions
├── middlewares/     # Auth, rate limiting, logging
├── validators/     # Input validation schemas (Joi, Zod)
├── utils/          # Pure helper functions
├── config/         # Environment-specific configuration
└── app.js          # Express/Fastify app setup
Enter fullscreen mode Exit fullscreen mode

Why This Matters

  • Controller Layer: Sanitizes input and manages the HTTP lifecycle. It should never contain business logic.
  • Service Layer: The brain of your app where business logic resides. This is where you enforce rules, orchestrate workflows, and call repositories.
  • Repository / Data Access Layer: Abstractions for your database (Mongoose, Prisma, Sequelize). If you ever switch databases, only this layer changes.
// ❌ BAD — Everything crammed into a route handler
app.post('/users', async (req, res) => {
  const { email, password } = req.body;
  // validation, hashing, DB call, email sending — all in one place
  const hashedPassword = await bcrypt.hash(password, 10);
  const user = await User.create({ email, password: hashedPassword });
  await sendWelcomeEmail(user.email);
  res.json(user);
});

// ✅ GOOD — Separated concerns
// controller/user.controller.js
const createUser = async (req, res, next) => {
  try {
    const user = await userService.register(req.body);
    res.status(201).json({ success: true, data: user });
  } catch (err) {
    next(err);
  }
};

// service/user.service.js
const register = async (userData) => {
  const existing = await userRepo.findByEmail(userData.email);
  if (existing) throw new ConflictError('Email already registered');

  const hashedPassword = await bcrypt.hash(userData.password, 10);
  const user = await userRepo.create({ ...userData, password: hashedPassword });
  await emailService.sendWelcome(user.email);
  return user;
};
Enter fullscreen mode Exit fullscreen mode

This separation means your services are testable without HTTP, your controllers are thin, and your data layer is swappable.


2. Resilient Error Management

Production apps shouldn't just run; they should fail gracefully. Here's the pattern I use on every project:

Create Custom Error Classes

// errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404);
  }
}

class ValidationError extends AppError {
  constructor(message) {
    super(message, 422);
  }
}

class ConflictError extends AppError {
  constructor(message) {
    super(message, 409);
  }
}
Enter fullscreen mode Exit fullscreen mode

Centralized Error Handler

// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Log the full error for debugging
  logger.error({
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    ip: req.ip,
    userId: req.user?.id,
  });

  // Operational errors — safe to show to client
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      status: 'error',
      message: err.message,
    });
  }

  // Programming errors — don't leak details
  res.status(500).json({
    status: 'error',
    message: 'Something went wrong. Please try again later.',
  });
};
Enter fullscreen mode Exit fullscreen mode

Handle Uncaught Exceptions

// Catch unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  logger.fatal('Unhandled Rejection:', reason);
  // Graceful shutdown
  server.close(() => process.exit(1));
});

// Catch uncaught exceptions
process.on('uncaughtException', (error) => {
  logger.fatal('Uncaught Exception:', error);
  // Graceful shutdown
  server.close(() => process.exit(1));
});
Enter fullscreen mode Exit fullscreen mode

Never use console.log for production errors. Use structured logging with Pino or Winston — they output JSON that your log aggregator (Datadog, ELK, CloudWatch) can parse and alert on.


3. Configuration Management

Hardcoded values are a production time bomb. Use environment-based configuration with validation:

// config/index.js
const Joi = require('joi');

const envSchema = Joi.object({
  NODE_ENV: Joi.string().valid('development', 'staging', 'production').required(),
  PORT: Joi.number().default(3000),
  DATABASE_URL: Joi.string().uri().required(),
  REDIS_URL: Joi.string().uri().required(),
  JWT_SECRET: Joi.string().min(32).required(),
  JWT_EXPIRY: Joi.string().default('7d'),
}).unknown(true);

const { error, value: env } = envSchema.validate(process.env);

if (error) {
  throw new Error(`Config validation error: ${error.message}`);
}

module.exports = {
  env: env.NODE_ENV,
  port: env.PORT,
  db: { url: env.DATABASE_URL },
  redis: { url: env.REDIS_URL },
  jwt: { secret: env.JWT_SECRET, expiry: env.JWT_EXPIRY },
};
Enter fullscreen mode Exit fullscreen mode

Your app crashes immediately on startup if config is invalid — much better than discovering a missing env var at 3 AM in production.


4. Performance & Monitoring

Monitor the Event Loop

If your Event Loop lag exceeds 50ms, you're likely performing blocking synchronous operations that starve throughput:

const { monitorEventLoopDelay } = require('perf_hooks');

const h = monitorEventLoopDelay({ resolution: 50 });
h.enable();

setInterval(() => {
  const p99 = h.percentile(99) / 1e6; // Convert to ms
  if (p99 > 50) {
    logger.warn(`Event loop lag (p99): ${p99.toFixed(2)}ms`);
  }
  h.reset();
}, 10000);
Enter fullscreen mode Exit fullscreen mode

Health Check Endpoint

Every production Node.js app needs a health check:

app.get('/health', async (req, res) => {
  const healthcheck = {
    uptime: process.uptime(),
    status: 'OK',
    timestamp: Date.now(),
    checks: {
      database: await checkDB(),
      redis: await checkRedis(),
      memory: process.memoryUsage(),
    },
  };
  res.json(healthcheck);
});
Enter fullscreen mode Exit fullscreen mode

Use APM Tools

Combine Application Performance Monitoring (Datadog, New Relic, or Elastic APM) with structured logging to catch bottlenecks before they impact users. These tools give you:

  • Request tracing across services
  • Database query performance
  • Memory leak detection
  • Error rate dashboards

5. Security Essentials

Production security in 60 seconds:

const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cors = require('cors');

// Security headers
app.use(helmet());

// CORS — be explicit, never use '*' in production
app.use(cors({
  origin: ['https://yourdomain.com'],
  credentials: true,
}));

// Rate limiting
app.use('/api/', rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: { error: 'Too many requests, slow down.' },
}));

// Prevent parameter pollution
app.use(hpp());

// Sanitize input against NoSQL injection
app.use(mongoSanitize());
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

Building for production is a marathon of consistency. By following these pillars — layered architecture, resilient error handling, validated configuration, proactive monitoring, and security defaults — you ensure your Node.js applications are ready to scale globally.

These aren't theoretical patterns. I've used every single one across enterprise projects handling millions of requests. They work.


What production patterns have saved you? Drop them in the comments below 👇

If you want to see more of my work or discuss a project, feel free to visit my portfolio at muhammadarslan.codes or connect with me on LinkedIn and GitHub.

Top comments (0)