Node.js powers millions of production APIs. But the patterns that work in a weekend project often fail at scale — or worse, introduce critical security vulnerabilities. This guide covers 12 essential best practices for Node.js applications in 2026.
1. Validate All Input at the System Boundary
Never trust data from outside your application: HTTP requests, environment variables, database results, file contents.
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['user', 'admin']).default('user'),
});
app.post('/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const user = result.data; // fully typed, validated
// ...
});
Use Zod for runtime validation + TypeScript type inference in one step. Every Express route handler should validate before doing any business logic.
2. Never Store Secrets in Code
Use environment variables for secrets. Use .env for local development (never commit it). Use a secrets manager for production.
# .env.example (safe to commit)
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=change-me-in-production
REDIS_URL=redis://localhost:6379
# .env (never commit — add to .gitignore)
DATABASE_URL=postgresql://prod-server:5432/myapp
JWT_SECRET=f8a9...
// Load once at startup, fail fast if required vars missing
const config = {
dbUrl: process.env.DATABASE_URL ?? (() => { throw new Error('DATABASE_URL required') })(),
jwtSecret: process.env.JWT_SECRET ?? (() => { throw new Error('JWT_SECRET required') })(),
port: parseInt(process.env.PORT ?? '3000', 10),
};
For production: AWS Secrets Manager, HashiCorp Vault, or Doppler for centralized secret management with rotation.
3. Use Helmet for HTTP Security Headers
import helmet from 'helmet';
app.use(helmet()); // sets 15+ security headers in one line
// What helmet adds:
// Content-Security-Policy
// X-Content-Type-Options: nosniff
// X-Frame-Options: DENY
// Strict-Transport-Security
// X-XSS-Protection
// ...and more
These headers prevent a class of attacks including clickjacking, MIME sniffing, and some XSS vectors. Enable them before any routes.
4. Rate Limit Every Endpoint
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Global: 100 req/15 min per IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({ /* redis client */ }), // use Redis in production
});
// Auth endpoints: stricter
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many auth attempts, try again in 15 minutes' },
});
app.use(globalLimiter);
app.use('/auth', authLimiter);
Without rate limiting, a single bot can exhaust your server, brute-force credentials, or run up your AI API bill.
5. Handle Errors Consistently
// Custom error classes with HTTP status mapping
class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public code?: string
) {
super(message);
this.name = 'AppError';
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(message: string) {
super(message, 400, 'VALIDATION_ERROR');
}
}
// Central error handler (must be last middleware)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: { code: err.code, message: err.message }
});
}
// Unexpected errors: log full details, return generic message
console.error('Unexpected error:', err);
res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'An error occurred' } });
});
Never expose stack traces to clients in production. Log internally, return safe messages externally.
6. Use async/await Correctly — Avoid Promise Hell
// ❌ Callback hell (legacy)
db.query('SELECT...', function(err, users) {
if (err) { return callback(err); }
users.forEach(function(user) {
sendEmail(user.email, function(err) {
// ...
});
});
});
// ❌ Floating promise — errors are swallowed
app.get('/users', (req, res) => {
getUsers().then(users => res.json(users)); // no .catch()!
});
// ✅ async/await with proper error handling
app.get('/users', async (req, res, next) => {
try {
const users = await getUsers();
res.json(users);
} catch (error) {
next(error); // pass to central error handler
}
});
// ✅ Parallel when independent
const [users, products] = await Promise.all([
getUsers(),
getProducts(),
]);
7. Implement Structured Logging
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined,
});
// Add request ID and context to every log
app.use((req, res, next) => {
req.log = logger.child({
requestId: crypto.randomUUID(),
method: req.method,
path: req.path,
});
next();
});
// Log business events with structured data
req.log.info({ userId: user.id, action: 'login' }, 'User logged in');
req.log.error({ error: err.message, stack: err.stack }, 'Database query failed');
Structured JSON logs are queryable in tools like Datadog, CloudWatch, or Loki. console.log strings are not.
8. Prevent SQL Injection with Parameterized Queries
// ❌ String concatenation = SQL injection
const users = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// ✅ Parameterized query
const users = await db.query('SELECT * FROM users WHERE email = $1', [email]);
// ✅ ORM (Prisma, Drizzle) — parameterization is automatic
const user = await prisma.user.findUnique({ where: { email } });
A single string interpolation in a query can compromise your entire database. Use parameterized queries or an ORM — always.
9. Set Timeouts on External Calls
// ❌ No timeout — hangs forever if the API is slow
const data = await fetch('https://api.example.com/data');
// ✅ Abort after 5 seconds
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const data = await fetch('https://api.example.com/data', {
signal: controller.signal,
});
return await data.json();
} finally {
clearTimeout(timeoutId);
}
// ✅ Database query timeout (Prisma example)
const result = await prisma.$queryRaw`SELECT...`
// Add timeout via Prisma middleware or connection pool settings
Without timeouts, a slow downstream service can exhaust your connection pool and take down your entire application.
10. Monitor Memory Usage
// Log memory stats periodically
setInterval(() => {
const { rss, heapUsed, heapTotal } = process.memoryUsage();
logger.info({
rss: Math.round(rss / 1024 / 1024),
heapUsed: Math.round(heapUsed / 1024 / 1024),
heapTotal: Math.round(heapTotal / 1024 / 1024),
}, 'Memory usage (MB)');
}, 60_000);
// Graceful shutdown to prevent memory dumps on SIGTERM
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully');
await server.close();
await db.disconnect();
process.exit(0);
});
Memory leaks in Node.js often come from: event listener accumulation (not removing listeners), global caches that never expire, closure references that prevent GC.
11. Use Connection Pooling for Databases
// ❌ New connection per request — exhausts DB connections fast
app.get('/users', async (req, res) => {
const db = new Client(); // new connection each time!
await db.connect();
const users = await db.query('SELECT * FROM users');
await db.end();
res.json(users);
});
// ✅ Connection pool (pg-pool example)
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // max connections
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 2_000,
});
app.get('/users', async (req, res, next) => {
const client = await pool.connect();
try {
const { rows } = await client.query('SELECT * FROM users');
res.json(rows);
} finally {
client.release(); // returns connection to pool
}
});
12. Health Check Endpoint for Production
app.get('/health', async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: new Date().toISOString(),
status: 'ok',
services: {} as Record<string, string>,
};
try {
await pool.query('SELECT 1'); // DB ping
checks.services.database = 'ok';
} catch {
checks.services.database = 'error';
checks.status = 'degraded';
}
const statusCode = checks.status === 'ok' ? 200 : 503;
res.status(statusCode).json(checks);
});
Load balancers and Kubernetes use /health to route traffic. If it returns 503, traffic is removed from that instance automatically.
Quick Reference Checklist
| Practice | Tool/Pattern |
|---|---|
| Input validation | Zod, Joi |
| Security headers | Helmet |
| Rate limiting | express-rate-limit + Redis |
| Structured logging | Pino, Winston |
| Secret management | dotenv + AWS Secrets Manager |
| Error handling | Centralized middleware |
| SQL safety | Parameterized queries / ORM |
| Timeouts | AbortController, pg timeout |
| Memory monitoring | process.memoryUsage() |
| Connection pooling | pg-pool, Prisma |
Related Tools
- JSON Formatter — format API response debugging
- Regex Tester — test validation patterns
- UUID Generator — generate request IDs
- Hash Generator — verify password hashing behavior
Level Up Your Dev Workflow
Found this useful? Explore DevPlaybook — cheat sheets, tool comparisons, and hands-on guides for modern developers.
🛒 Get the DevToolkit Starter Kit on Gumroad — 40+ browser-based dev tools, source code + deployment guide included.
Top comments (0)