← All posts

Node.js Backend Patterns and Performance: What Actually Matters in Production

Node.js Backend Patterns and Performance: What Actually Matters in Production

I've seen too many Node.js backends collapse under load because engineers optimized the wrong things. They micro-optimized array methods while ignoring blocking I/O. They added caching layers that increased complexity without reducing database load. After years of running Node.js services handling millions of requests daily, I've learned that performance in production comes down to a handful of patterns that most teams ignore.

The Event Loop Is Your Bottleneck (And You're Probably Blocking It)

Node.js is single-threaded. Every millisecond you spend doing synchronous work is a millisecond your server can't process other requests. The most common performance killer I see? Synchronous JSON parsing of large payloads. When you call JSON.parse() on a 5MB webhook payload, you're blocking the event loop for 50-100ms. At 1000 req/s, that's a disaster.

// Bad: Blocking the event loop
app.post('/webhook', express.json({ limit: '10mb' }), (req, res) => {
  // express.json() already parsed synchronously - damage done
  processWebhook(req.body);
  res.sendStatus(200);
});

// Better: Stream and parse incrementally
const JSONStream = require('JSONStream');

app.post('/webhook', (req, res) => {
  const parser = JSONStream.parse('*');
  
  req.pipe(parser);
  
  parser.on('data', (chunk) => {
    // Process incrementally, yielding to event loop
    setImmediate(() => processWebhookChunk(chunk));
  });
  
  parser.on('end', () => res.sendStatus(200));
  parser.on('error', (err) => res.status(400).send(err.message));
});
Rule of thumb: If any synchronous operation takes more than 10ms, you need to either offload it to a worker thread or break it into async chunks using setImmediate().

Connection Pooling: The Optimization Everyone Forgets

Database connection pools are configured once and forgotten. Then production traffic spikes and suddenly your app is spending 80% of its time waiting for available connections. Default pool sizes are almost always wrong. PostgreSQL's default is 10 connections. MongoDB's is 5. For a service handling 100 concurrent requests, that's a 10:1 queue.

// Naive approach - connection per request
const { Pool } = require('pg');
const pool = new Pool({
  max: 10, // Default - way too small
  idleTimeoutMillis: 30000
});

// Production-ready approach
const pool = new Pool({
  max: process.env.DB_POOL_SIZE || 50,
  min: 10, // Keep warm connections
  idleTimeoutMillis: 60000,
  connectionTimeoutMillis: 5000,
  // Critical: limit queue size to fail fast
  maxUses: 7500, // Recycle connections
});

// Monitor pool health
setInterval(() => {
  console.log({
    total: pool.totalCount,
    idle: pool.idleCount,
    waiting: pool.waitingCount
  });
}, 10000);

I size pools based on this formula: (num_cpu_cores * 2) + effective_spindle_count. For cloud databases, start with 50 connections per instance and monitor. If waitingCount is consistently above 0, increase the pool. If queries are slow but idleCount is high, your bottleneck is elsewhere.

The Repository Pattern Prevents Performance Disasters

Raw SQL scattered across route handlers makes performance optimization impossible. You can't identify N+1 queries. You can't add caching layers. You can't switch databases. The repository pattern isn't just about clean architecture—it's about having a single place to optimize data access.

// repositories/UserRepository.js
class UserRepository {
  constructor(pool, cache) {
    this.pool = pool;
    this.cache = cache;
  }

  async findById(id) {
    const cacheKey = `user:${id}`;
    const cached = await this.cache.get(cacheKey);
    if (cached) return JSON.parse(cached);

    const result = await this.pool.query(
      'SELECT * FROM users WHERE id = $1',
      [id]
    );
    
    if (result.rows[0]) {
      await this.cache.set(cacheKey, JSON.stringify(result.rows[0]), 'EX', 300);
    }
    
    return result.rows[0];
  }

  async findByIds(ids) {
    // Single query instead of N queries
    const result = await this.pool.query(
      'SELECT * FROM users WHERE id = ANY($1)',
      [ids]
    );
    return result.rows;
  }
}

module.exports = UserRepository;

Now when you discover an N+1 query in production, you fix it in one place. When you need to add DataLoader for GraphQL, you modify the repository. When Redis becomes a bottleneck, you swap in a local LRU cache. This pattern has saved me countless times during incidents.

Async Middleware Chains Will Burn You

Express middleware seems elegant until you have 12 middleware functions running on every request. Each one adds latency. Worse, if one middleware does blocking work, it affects every request. I've seen authentication middleware that validates JWTs synchronously add 50ms to every single request.

  • Minimize middleware count: Combine related middleware (auth + rate limiting) into single functions
  • Make middleware conditional: Only run body parsing on routes that need it, not globally
  • Cache aggressively: JWT validation results can be cached in-memory for 30-60 seconds
  • Profile middleware: Add timing logs to identify which middleware is slow
In one project, we reduced P99 latency by 200ms by simply removing unused middleware and making body parsing route-specific instead of global. The fastest code is the code you don't run.

Worker Threads for CPU-Bound Tasks

If you're doing image processing, PDF generation, or complex data transformations in your request handlers, you're doing it wrong. These tasks should run in worker threads or separate processes. I use worker_threads for short CPU-intensive tasks and Bull queues for longer jobs. The pattern is simple: accept the request, queue the work, return immediately.

const { Worker } = require('worker_threads');
const Queue = require('bull');

const imageQueue = new Queue('image-processing', process.env.REDIS_URL);

app.post('/images/process', async (req, res) => {
  const job = await imageQueue.add({
    imageUrl: req.body.imageUrl,
    operations: req.body.operations
  });
  
  res.json({ jobId: job.id, status: 'processing' });
});

// worker.js - separate process
imageQueue.process(async (job) => {
  const { imageUrl, operations } = job.data;
  
  // CPU-intensive work happens here, not in main process
  const result = await processImage(imageUrl, operations);
  
  return result;
});

This pattern transformed our API response times from 2-3 seconds to under 100ms. Users get instant feedback, and heavy processing happens asynchronously. If your API endpoints take more than 500ms, you're probably doing work that should be queued. The only exception is when users explicitly need to wait for results—and even then, consider WebSockets for progress updates.