DEV Community

楊東霖
楊東霖

Posted on • Originally published at devplaybook.cc

Node.js Best Practices for Security and Performance in 2026

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
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode
// 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),
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' } });
});
Enter fullscreen mode Exit fullscreen mode

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(),
]);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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


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)