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/, andmodels/ - 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,
},
};
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;
}
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);
});
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
);
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));
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;
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());
}
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();
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
);
}
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'));
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 auditclean
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
- Node.js Best Practices — the canonical community reference
- Express Security Best Practices
- OWASP Node.js Security Cheat Sheet
- Node.js Documentation
- Pino Logger
- Node.js Diagnostics Guide
Our Take
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.