Frameworks7 min read

Node.js Best Practices 2025: Build Production-Ready Applications

Master Node.js development with these essential best practices covering security, performance, error handling, and scalability for production applications.

Zeeshan Shahid
Zeeshan Shahid
June 25, 2025
Share:
Node.js Best Practices 2025: Build Production-Ready Applications

Building production-ready Node.js applications takes more than code that works on your laptop. The gap between "it runs" and "it survives" is made up of error handling, input validation, connection management, structured logging, and graceful shutdown — the unglamorous work that determines whether an incident is a blip or an outage.

This guide covers the patterns that matter, with the reasoning behind each one.

Project Structure and Architecture

Feature-Based Organization

Grouping by feature rather than by technical layer keeps related code together:

src/
├── users/
│   ├── user.controller.js     # HTTP handlers
│   ├── user.service.js         # Business logic
│   ├── user.repository.js      # Database access
│   ├── user.model.js           # Data models
│   ├── user.routes.js          # Route definitions
│   ├── user.validation.js      # Input validation
│   ├── user.test.js            # Tests
│   └── index.js                # Module exports
├── bookings/
│   ├── booking.controller.js
│   ├── booking.service.js
│   ├── booking.repository.js
│   └── ...
├── payments/
│   ├── payment.controller.js
│   ├── payment.service.js
│   ├── payment.gateway.js      # External API wrapper
│   └── ...
└── shared/
    ├── middleware/
    │   ├── auth.js
    │   ├── errorHandler.js
    │   ├── validation.js
    │   └── rateLimit.js
    ├── utils/
    │   ├── logger.js
    │   ├── cache.js
    │   └── db.js
    ├── config/
    │   ├── index.js
    │   ├── database.js
    │   └── redis.js
    └── types/

Why feature-based tends to beat layer-based:

  • Navigation: Everything about users is in one directory, not spread across controllers/, services/, and models/
  • Encapsulation: Each feature is self-contained with a clear public surface via index.js
  • Testing: Clear boundaries make mocking straightforward
  • Ownership: Different teams can own different features without constant merge conflicts
  • Dependency clarity: Cross-feature imports are visible, so coupling is obvious

Environment Configuration

Validate configuration at startup so a missing environment variable fails immediately rather than at 2 AM when the code path finally runs:

// config/index.js
import dotenv from 'dotenv';
import Joi from 'joi';

dotenv.config();

const envSchema = Joi.object({
  NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
  PORT: Joi.number().default(3000),
  DATABASE_URL: Joi.string().required(),
  REDIS_URL: Joi.string().required(),
  JWT_SECRET: Joi.string().min(32).required(),
  JWT_EXPIRES_IN: Joi.string().default('7d'),
  LOG_LEVEL: Joi.string().valid('error', 'warn', 'info', 'debug').default('info'),
  SENTRY_DSN: Joi.string().when('NODE_ENV', {
    is: 'production',
    then: Joi.required(),
  }),
}).unknown();

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

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

export default {
  env: env.NODE_ENV,
  port: env.PORT,
  database: {
    url: env.DATABASE_URL,
    pool: {
      min: 2,
      max: env.NODE_ENV === 'production' ? 20 : 10,
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 2000,
    },
  },
  redis: {
    url: env.REDIS_URL,
    maxRetriesPerRequest: 3,
    enableReadyCheck: true,
    lazyConnect: true,
  },
  jwt: {
    secret: env.JWT_SECRET,
    expiresIn: env.JWT_EXPIRES_IN,
    algorithm: 'HS256',
  },
  logging: {
    level: env.LOG_LEVEL,
  },
  monitoring: {
    sentryDSN: env.SENTRY_DSN,
  },
};
Fail fast on config

The JWT_SECRET: Joi.string().min(32).required() line is doing real security work. Without validation, a missing JWT_SECRET becomes undefined, and some libraries will happily sign tokens with it. A crash at startup is infinitely better than an authentication system that silently accepts forged tokens.


Error Handling

Error handling is where most production Node.js problems originate. An unhandled rejection in one request handler can take down the process; a swallowed error can corrupt data silently.

Custom Error Hierarchy

The key distinction is operational errors (expected: bad input, missing resource) versus programmer errors (bugs). Operational errors get returned to the client; programmer errors get logged and hidden.

// shared/errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = isOperational;

    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, errors = []) {
    super(message, 400);
    this.errors = errors;
    this.name = 'ValidationError';
  }
}

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

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401);
    this.name = 'UnauthorizedError';
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Forbidden') {
    super(message, 403);
    this.name = 'ForbiddenError';
  }
}

class ConflictError extends AppError {
  constructor(message) {
    super(message, 409);
    this.name = 'ConflictError';
  }
}

class TooManyRequestsError extends AppError {
  constructor(retryAfter = 60) {
    super('Too many requests', 429);
    this.retryAfter = retryAfter;
    this.name = 'TooManyRequestsError';
  }
}

export {
  AppError,
  ValidationError,
  NotFoundError,
  UnauthorizedError,
  ForbiddenError,
  ConflictError,
  TooManyRequestsError,
};

Global Error Handler

// middleware/errorHandler.js
import logger from '../utils/logger.js';
import * as Sentry from '@sentry/node';

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

  // Log with request context — but sanitize the body first
  logger.error({
    err: {
      message: err.message,
      stack: err.stack,
      statusCode: err.statusCode,
    },
    req: {
      method: req.method,
      url: req.url,
      body: sanitizeBody(req.body),
      query: req.query,
      params: req.params,
      ip: req.ip,
      userId: req.user?.id,
    },
  }, 'Request error');

  // Report unexpected errors to Sentry
  if (!err.isOperational || err.statusCode >= 500) {
    Sentry.captureException(err, {
      user: req.user ? { id: req.user.id } : undefined,
      extra: {
        body: sanitizeBody(req.body),
        query: req.query,
        params: req.params,
      },
    });
  }

  // Development: full detail
  if (process.env.NODE_ENV === 'development') {
    return res.status(err.statusCode).json({
      status: err.status,
      error: err,
      message: err.message,
      stack: err.stack,
    });
  }

  // Production: operational errors are safe to surface
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      status: err.status,
      message: err.message,
      ...(err.errors && { errors: err.errors }),
    });
  }

  // Programmer error — log it, don't leak it
  logger.fatal({ err }, 'Non-operational error');
  return res.status(500).json({
    status: 'error',
    message: 'Something went wrong. Please try again later.',
  });
}

function sanitizeBody(body) {
  if (!body) return undefined;
  const sanitized = { ...body };
  const sensitiveFields = ['password', 'token', 'creditCard', 'ssn'];
  sensitiveFields.forEach(field => {
    if (sanitized[field]) {
      sanitized[field] = '[REDACTED]';
    }
  });
  return sanitized;
}
Don't log raw request headers

The Authorization header contains a bearer token. Logging req.headers wholesale writes valid credentials into your log aggregator, where they're indexed and searchable by anyone with dashboard access. Log the fields you need, not the object.

Process-Level Handlers

// Unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  logger.fatal({ reason, promise }, 'Unhandled Rejection');
  Sentry.captureException(reason);
  // Give the logger and Sentry time to flush before exiting
  setTimeout(() => process.exit(1), 1000);
});

// Uncaught exceptions
process.on('uncaughtException', (error) => {
  logger.fatal({ err: error }, 'Uncaught Exception');
  Sentry.captureException(error);
  // The process is in an undefined state — it must exit
  setTimeout(() => process.exit(1), 1000);
});
Exiting on uncaughtException is correct, not defeatist

It's tempting to log the error and keep running. Don't. After an uncaught exception the process may hold half-released locks, half-written state, and leaked handles. Crash and let your orchestrator restart into a known-good state — that's what liveness probes and restart policies are for.

Async Error Wrapping

Express (before v5) doesn't catch rejected promises from async handlers, so a thrown error in an async route silently hangs the request. This wrapper fixes it:

// utils/asyncHandler.js
export function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// Usage in controllers
export const getUser = asyncHandler(async (req, res) => {
  const user = await userService.getById(req.params.id);

  if (!user) {
    throw new NotFoundError('User');
  }

  res.json({ user });
});

No try-catch needed in every handler.


Security Best Practices

Input Validation with Zod

Validate at the boundary. Everything inside your service layer should be able to trust its inputs:

// users/user.validation.js
import { z } from 'zod';

export const createUserSchema = z.object({
  body: z.object({
    email: z.string().email().toLowerCase().trim(),
    password: z.string()
      .min(8, 'Password must be at least 8 characters')
      .max(100)
      .regex(/[A-Z]/, 'Password must contain uppercase')
      .regex(/[a-z]/, 'Password must contain lowercase')
      .regex(/[0-9]/, 'Password must contain number')
      .regex(/[^A-Za-z0-9]/, 'Password must contain special character'),
    name: z.string().min(2).max(50).trim(),
    role: z.enum(['user', 'admin']).default('user'),
  }),
});

export const updateUserSchema = z.object({
  params: z.object({
    id: z.string().uuid(),
  }),
  body: z.object({
    name: z.string().min(2).max(50).trim().optional(),
    bio: z.string().max(500).optional(),
  }),
});

// Validation middleware
export function validate(schema) {
  return (req, res, next) => {
    try {
      schema.parse({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        const errors = error.errors.map(err => ({
          field: err.path.join('.'),
          message: err.message,
        }));
        throw new ValidationError('Validation failed', errors);
      }
      throw error;
    }
  };
}

// Route usage
router.post('/users',
  validate(createUserSchema),
  userController.createUser
);
Password max length isn't arbitrary

The .max(100) matters more than it looks. bcrypt truncates input beyond 72 bytes, and unbounded password input is a cheap denial-of-service vector — hashing a 10MB "password" burns CPU on every attempt. Cap it.

Rate Limiting Strategy

Different endpoints warrant different limits. Login endpoints in particular need much tighter limits than general reads:

// middleware/rateLimit.js
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../utils/redis.js';

// General API rate limit
export const apiLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:api:',
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: 'Too many requests from this IP, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    throw new TooManyRequestsError(15 * 60);
  },
});

// Strict auth rate limit — slows credential stuffing
export const authLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:auth:',
  }),
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // Only count failed attempts
  handler: (req, res) => {
    logger.warn({
      ip: req.ip,
      endpoint: req.path,
    }, 'Auth rate limit exceeded');
    throw new TooManyRequestsError(15 * 60);
  },
});

// Per-user rate limit
export function createUserLimiter(max = 1000) {
  return rateLimit({
    store: new RedisStore({
      client: redis,
      prefix: 'rl:user:',
    }),
    windowMs: 60 * 60 * 1000, // 1 hour
    max,
    keyGenerator: (req) => req.user?.id || req.ip,
    skip: (req) => !req.user,
  });
}

// Usage
app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);
app.use('/api/protected/', createUserLimiter(1000));
Use a shared store, and set trust proxy correctly

The default in-memory store gives each instance its own counter — behind a load balancer, an attacker's effective limit multiplies by your instance count. Redis fixes that. Separately, if you're behind a proxy and haven't set app.set('trust proxy', ...), req.ip will be your load balancer's address, and you'll rate-limit every user as one client.

Secure JWT Implementation

// services/auth.service.js
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import config from '../config/index.js';

class AuthService {
  static async hashPassword(password) {
    return bcrypt.hash(password, 12);
  }

  static async comparePassword(password, hash) {
    return bcrypt.compare(password, hash);
  }

  static generateAccessToken(userId, role) {
    return jwt.sign(
      { userId, role, type: 'access' },
      config.jwt.secret,
      {
        expiresIn: '15m', // Short-lived — refresh tokens carry the longevity
        algorithm: 'HS256',
        issuer: 'myapp',
        audience: 'myapp-client',
      }
    );
  }

  static generateRefreshToken() {
    return crypto.randomBytes(40).toString('hex');
  }

  static verifyAccessToken(token) {
    try {
      // Pinning `algorithms` prevents algorithm-confusion attacks
      const decoded = jwt.verify(token, config.jwt.secret, {
        algorithms: ['HS256'],
        issuer: 'myapp',
        audience: 'myapp-client',
      });

      if (decoded.type !== 'access') {
        throw new Error('Invalid token type');
      }

      return decoded;
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        throw new UnauthorizedError('Token expired');
      }
      if (error.name === 'JsonWebTokenError') {
        throw new UnauthorizedError('Invalid token');
      }
      throw error;
    }
  }

  static async login(email, password) {
    const user = await User.findByEmail(email);

    // Single generic error for both cases — don't reveal which emails exist
    if (!user || !(await this.comparePassword(password, user.password))) {
      throw new UnauthorizedError('Invalid credentials');
    }

    const accessToken = this.generateAccessToken(user.id, user.role);
    const refreshToken = this.generateRefreshToken();

    await RefreshToken.create({
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    });

    return {
      accessToken,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role,
      },
    };
  }
}

export default AuthService;
Always pin the algorithm on verify

Passing algorithms: ['HS256'] to jwt.verify is not optional boilerplate. Without it, some JWT libraries will honour the alg field in the attacker-supplied token header — including none. Pin it explicitly, and never let a token tell you how to verify itself.

Security Headers

// middleware/security.js
import helmet from 'helmet';
import hpp from 'hpp';

export function securityMiddleware(app) {
  app.use(helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", 'data:', 'https:'],
        connectSrc: ["'self'"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
      },
    },
    crossOriginEmbedderPolicy: true,
    crossOriginOpenerPolicy: true,
    crossOriginResourcePolicy: { policy: 'cross-origin' },
    frameguard: { action: 'deny' },
    hidePoweredBy: true,
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
    noSniff: true,
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  }));

  // Prevent HTTP Parameter Pollution
  app.use(hpp());
}
'unsafe-inline' in scriptSrc defeats the CSP

A Content-Security-Policy with scriptSrc: ["'self'", "'unsafe-inline'"] permits exactly the inline injection CSP exists to stop. If you need inline scripts, use a nonce or hash. And note that hsts with preload is effectively irreversible for your domain — be sure before enabling it.


Performance Optimization

Database Connection Pooling

Opening a connection per query is one of the most common Node.js performance mistakes — TCP setup plus authentication dominates the actual query time. A pool amortises that:

// utils/db.js
import { Pool } from 'pg';
import config from '../config/index.js';
import logger from './logger.js';

class Database {
  constructor() {
    this.pool = new Pool({
      connectionString: config.database.url,
      ...config.database.pool,
    });

    // A pool error means a connection died — log it, don't crash
    this.pool.on('error', (err) => {
      logger.error({ err }, 'Unexpected pool error');
    });

    // Pool saturation is an early warning sign; watch waitingCount
    setInterval(() => {
      logger.info({
        totalCount: this.pool.totalCount,
        idleCount: this.pool.idleCount,
        waitingCount: this.pool.waitingCount,
      }, 'Database pool metrics');
    }, 60000);
  }

  async query(text, params) {
    const start = Date.now();
    try {
      const result = await this.pool.query(text, params);
      const duration = Date.now() - start;

      logger.debug({
        query: text,
        duration,
        rows: result.rowCount,
      }, 'Query executed');

      return result;
    } catch (error) {
      logger.error({ err: error, query: text }, 'Query failed');
      throw error;
    }
  }

  async transaction(callback) {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
      const result = await callback(client);
      await client.query('COMMIT');
      return result;
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      // The finally block is load-bearing — without it, a thrown error
      // leaks the connection and eventually exhausts the pool
      client.release();
    }
  }

  async healthCheck() {
    try {
      await this.query('SELECT 1');
      return true;
    } catch (error) {
      logger.error({ err: error }, 'Database health check failed');
      return false;
    }
  }

  async close() {
    await this.pool.end();
    logger.info('Database pool closed');
  }
}

export default new Database();
Pool size is not a bigger-is-better setting

Your pool max multiplied by your instance count must stay below the database's max_connections. Twenty instances with a max of 20 each is 400 connections — enough to lock out a default PostgreSQL configuration. If you need high client concurrency, put PgBouncer in front rather than raising pool sizes.

Redis Caching Layer

Note the deliberate design choice below: every cache operation fails open. A Redis outage should degrade performance, not take down the application.

// utils/cache.js
import Redis from 'ioredis';
import config from '../config/index.js';
import logger from './logger.js';

class CacheService {
  constructor() {
    this.redis = new Redis(config.redis.url, {
      ...config.redis,
      retryStrategy: (times) => {
        const delay = Math.min(times * 50, 2000);
        logger.warn({ times, delay }, 'Redis retrying connection');
        return delay;
      },
    });

    this.redis.on('connect', () => logger.info('Redis connected'));
    this.redis.on('error', (err) => logger.error({ err }, 'Redis error'));
  }

  async get(key) {
    try {
      const cached = await this.redis.get(key);
      if (cached) {
        return JSON.parse(cached);
      }
      return null;
    } catch (error) {
      logger.error({ err: error, key }, 'Cache get error');
      return null; // Fail open — treat as a cache miss
    }
  }

  async set(key, value, ttl = 3600) {
    try {
      await this.redis.setex(key, ttl, JSON.stringify(value));
    } catch (error) {
      logger.error({ err: error, key }, 'Cache set error');
    }
  }

  async del(key) {
    try {
      await this.redis.del(key);
    } catch (error) {
      logger.error({ err: error, key }, 'Cache delete error');
    }
  }

  async mget(keys) {
    try {
      const values = await this.redis.mget(keys);
      return values.map(v => v ? JSON.parse(v) : null);
    } catch (error) {
      logger.error({ err: error, keys }, 'Cache mget error');
      return new Array(keys.length).fill(null);
    }
  }

  // Cache-aside pattern helper
  async wrap(key, fn, ttl = 3600) {
    const cached = await this.get(key);
    if (cached !== null) {
      return cached;
    }

    const result = await fn();
    await this.set(key, result, ttl);
    return result;
  }
}

export default new CacheService();

// Usage
async function getUser(id) {
  return cache.wrap(
    `user:${id}`,
    () => db.query('SELECT * FROM users WHERE id = $1', [id]),
    3600
  );
}
Never use KEYS for cache invalidation in production

A common pattern is redis.keys('user:*') followed by a bulk delete. KEYS scans the entire keyspace and blocks Redis — single-threaded — for the duration. On a large database that's a multi-second stall for every client. Use SCAN for iteration, or better, structure keys so you can invalidate deterministically without searching.


Structured Logging

Plain-text logs are unsearchable at scale. JSON logs can be filtered, aggregated, and alerted on:

// utils/logger.js
import pino from 'pino';
import config from '../config/index.js';

const logger = pino({
  level: config.logging.level,
  formatters: {
    level: (label) => ({ level: label }),
  },
  serializers: {
    // Serializers control exactly which fields get logged —
    // note that Authorization is deliberately not among them
    req: (req) => ({
      method: req.method,
      url: req.url,
      headers: {
        host: req.headers.host,
        userAgent: req.headers['user-agent'],
      },
      remoteAddress: req.remoteAddress,
    }),
    res: (res) => ({
      statusCode: res.statusCode,
    }),
    err: pino.stdSerializers.err,
  },
  transport: config.env === 'development' ? {
    target: 'pino-pretty',
    options: {
      colorize: true,
      translateTime: 'HH:MM:ss Z',
      ignore: 'pid,hostname',
    },
  } : undefined,
});

export default logger;

// HTTP request logging middleware
export function requestLogger(req, res, next) {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    logger.info({
      req,
      res,
      duration,
      userId: req.user?.id,
    }, 'HTTP request');
  });

  next();
}

Pino is designed to be fast and to write JSON by default. Pretty-printing is a development-only transport — running pino-pretty in production adds overhead for output nothing will read.


Testing Strategy

Unit Tests

// users/user.service.test.js
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import UserService from './user.service.js';
import * as UserRepository from './user.repository.js';
import { ValidationError, ConflictError, NotFoundError } from '../shared/errors/index.js';

jest.mock('./user.repository.js');

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

  describe('createUser', () => {
    it('should create user with valid data', async () => {
      const userData = {
        email: 'test@example.com',
        password: 'Password123!',
        name: 'Test User',
      };

      const mockUser = { id: '123', ...userData, password: '[HASHED]' };
      UserRepository.create.mockResolvedValue(mockUser);

      const result = await UserService.createUser(userData);

      expect(result).toHaveProperty('id');
      expect(result.email).toBe(userData.email);
      // Assert the password was actually hashed, not just stored
      expect(result.password).not.toBe(userData.password);
      expect(UserRepository.create).toHaveBeenCalledTimes(1);
    });

    it('should throw ValidationError with invalid email', async () => {
      await expect(UserService.createUser({
        email: 'invalid-email',
        password: 'Password123!',
        name: 'Test User',
      })).rejects.toThrow(ValidationError);
    });

    it('should throw ConflictError if email exists', async () => {
      UserRepository.findByEmail.mockResolvedValue({ id: '123' });

      await expect(UserService.createUser({
        email: 'existing@example.com',
        password: 'Password123!',
        name: 'Test User',
      })).rejects.toThrow(ConflictError);
    });
  });

  describe('getUserById', () => {
    it('should return user when found', async () => {
      const mockUser = { id: '123', email: 'test@example.com', name: 'Test User' };
      UserRepository.findById.mockResolvedValue(mockUser);

      const result = await UserService.getUserById('123');

      expect(result).toEqual(mockUser);
      expect(UserRepository.findById).toHaveBeenCalledWith('123');
    });

    it('should throw NotFoundError when user not found', async () => {
      UserRepository.findById.mockResolvedValue(null);

      await expect(UserService.getUserById('999')).rejects.toThrow(NotFoundError);
    });
  });
});

Integration Tests

Unit tests with mocked repositories won't catch a broken SQL query or a missing middleware. Integration tests exercise the real stack:

// users/user.integration.test.js
import request from 'supertest';
import app from '../app.js';
import db from '../utils/db.js';

describe('User API Integration', () => {
  let authToken;
  let testUserId;

  beforeAll(async () => {
    await db.query('DELETE FROM users WHERE email LIKE $1', ['%@test.com']);
  });

  afterAll(async () => {
    await db.close();
  });

  describe('POST /api/users', () => {
    it('should create a new user', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({
          email: 'newuser@test.com',
          password: 'Password123!',
          name: 'New User',
        })
        .expect(201);

      expect(res.body.user).toHaveProperty('id');
      expect(res.body.user.email).toBe('newuser@test.com');
      // Regression guard: the password hash must never be serialized
      expect(res.body.user).not.toHaveProperty('password');

      testUserId = res.body.user.id;
    });

    it('should return 409 for duplicate email', async () => {
      await request(app)
        .post('/api/users')
        .send({
          email: 'newuser@test.com',
          password: 'Password123!',
          name: 'Duplicate User',
        })
        .expect(409);
    });

    it('should return 400 for invalid data', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ email: 'invalid-email', password: '123', name: 'A' })
        .expect(400);

      expect(res.body).toHaveProperty('errors');
      expect(res.body.errors.length).toBeGreaterThan(0);
    });
  });

  describe('GET /api/users/:id', () => {
    beforeEach(async () => {
      const res = await request(app)
        .post('/api/auth/login')
        .send({ email: 'newuser@test.com', password: 'Password123!' });

      authToken = res.body.accessToken;
    });

    it('should get user by id with auth', async () => {
      const res = await request(app)
        .get(`/api/users/${testUserId}`)
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);

      expect(res.body.user.id).toBe(testUserId);
    });

    it('should return 401 without auth token', async () => {
      await request(app)
        .get(`/api/users/${testUserId}`)
        .expect(401);
    });

    it('should return 404 for non-existent user', async () => {
      await request(app)
        .get('/api/users/00000000-0000-0000-0000-000000000000')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(404);
    });
  });
});

Health Checks and Graceful Shutdown

When an orchestrator sends SIGTERM, the default behaviour is to drop in-flight requests. Graceful shutdown finishes them first:

// server.js
import app from './app.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import db from './utils/db.js';
import cache from './utils/cache.js';

const server = app.listen(config.port, () => {
  logger.info({ port: config.port }, 'Server started');
});

// Health check — returns 503 when a dependency is down
app.get('/health', async (req, res) => {
  const health = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: 'ok',
    checks: {
      database: await db.healthCheck(),
      redis: cache.redis.status === 'ready',
    },
  };

  const status = Object.values(health.checks).every(Boolean) ? 200 : 503;
  res.status(status).json(health);
});

function gracefulShutdown(signal) {
  logger.info({ signal }, 'Shutdown signal received');

  // Stop accepting new connections; finish in-flight requests
  server.close(async () => {
    logger.info('HTTP server closed');

    try {
      await db.close();
      await cache.redis.quit();
      process.exit(0);
    } catch (error) {
      logger.fatal({ err: error }, 'Error during shutdown');
      process.exit(1);
    }
  });

  // Don't hang forever on a stuck connection
  setTimeout(() => {
    logger.fatal('Forced shutdown after timeout');
    process.exit(1);
  }, 30000);
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
This health endpoint drives your orchestrator, not a dashboard

Returning 503 when the database is unreachable lets Kubernetes pull the pod from the load balancer rotation instead of routing traffic to an instance that will only produce errors. Wire it to a readiness probe. But be careful about using the same endpoint for liveness — a shared database blip would then restart every instance at once.


Common Production Issues

Memory Leaks

Symptoms: memory climbing steadily, eventual OOM crashes, gradual slowdown.

The usual suspects are unbounded caches, growing module-scoped arrays, and event listeners added per-request but never removed.

import heapdump from 'heapdump';

// Trigger a heap snapshot on demand — compare two snapshots taken
// minutes apart to find what's growing
process.on('SIGUSR2', () => {
  const filename = `/tmp/heapdump-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(filename);
  logger.info({ filename }, 'Heap snapshot written');
});

// Track memory over time
setInterval(() => {
  const usage = process.memoryUsage();
  logger.info({
    rss: Math.round(usage.rss / 1024 / 1024),
    heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
    external: Math.round(usage.external / 1024 / 1024),
  }, 'Memory usage (MB)');
}, 60000);

Slow Database Queries

async function query(text, params) {
  const start = Date.now();
  const result = await pool.query(text, params);
  const duration = Date.now() - start;

  if (duration > 1000) {
    logger.warn({
      query: text,
      duration,
      rowCount: result.rowCount,
    }, 'Slow query detected');
  }

  return result;
}

Fixes: add indexes, run EXPLAIN ANALYZE to find the bottleneck, cache the result, paginate large result sets, and eliminate N+1 patterns. Our PostgreSQL optimization guide goes deep on this.

High CPU Usage

Node.js runs your JavaScript on a single thread. Any synchronous work — a big JSON.parse, a pathological regex, a crypto operation — blocks every other request on that instance.

// Offload CPU-intensive tasks to worker threads
import { Worker } from 'worker_threads';

function processHeavyTask(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./heavy-task-worker.js', {
      workerData: data,
    });

    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

Production Deployment Checklist

Security

  • [ ] All secrets in environment variables, validated at startup
  • [ ] No credentials or PII in logs
  • [ ] Rate limiting with a shared store on all public endpoints
  • [ ] Input validation on every route
  • [ ] Security headers configured, CSP without unsafe-inline
  • [ ] JWT verification pins the algorithm
  • [ ] CORS configured deliberately
  • [ ] Parameterized queries throughout
  • [ ] npm audit clean

Performance

  • [ ] Connection pooling sized against the database's connection limit
  • [ ] Caching implemented and failing open
  • [ ] Response compression enabled
  • [ ] Database indexes reviewed
  • [ ] N+1 queries eliminated
  • [ ] Pagination on list endpoints

Monitoring

  • [ ] Structured JSON logging
  • [ ] Error tracking configured
  • [ ] Health check endpoints wired to probes
  • [ ] Metrics exposed
  • [ ] Alerts on error rate and latency

Reliability

  • [ ] Graceful shutdown on SIGTERM
  • [ ] Process exits on uncaught exceptions
  • [ ] Rollback procedure tested
  • [ ] Runbook for common incidents

Testing

  • [ ] Unit tests with meaningful coverage
  • [ ] Integration tests against a real database
  • [ ] Load testing completed

Resources

Our Take

Key Takeaway

If you only take three things from this guide: validate configuration at startup so failures are loud and early, distinguish operational errors from programmer errors so you know what's safe to show a client, and let the process crash on uncaught exceptions rather than limping on in an undefined state.

Most of what makes a Node.js application production-ready is defensive and unglamorous. It doesn't ship features and it rarely gets celebrated. But the difference between an application that degrades gracefully under stress and one that fails catastrophically is almost entirely made up of these patterns — put in before you needed them.

Tags:nodejsjavascriptbackendbest-practicesproduction
Zeeshan Shahid

Zeeshan Shahid

Founder, DevPages

Zeeshan builds and maintains DevPages, a hand-curated directory of developer tools. He writes about the tools in the catalog and the trade-offs between them.

22 articles published

Related Articles