Node.js em produção: performance e PM2 cluster

Node.js roda em uma única thread por padrão, em uma VPS com 4 CPUs, você aproveita apenas 25% do processador. PM2 em modo cluster e Worker Threads mudam isso, distribuindo a carga entre todos os núcleos. Combinado com compressão, cache de respostas e profiling, você multiplica o throughput sem mudar o hardware.

PM2 em modo cluster

Aproveitar todos os CPUs da VPS com PM2:

javascript
# ecosystem.config.js
module.exports = {
  apps: [{
    name: 'minha-api',
    script: './dist/index.js',
    instances: 'max',        // um processo por CPU
    exec_mode: 'cluster',    // compartilhar porta entre processos
    watch: false,
    max_memory_restart: '500M',
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
    // Zero-downtime deploy:
    wait_ready: true,
    listen_timeout: 10000,
    kill_timeout: 5000,
  }],
}

# Iniciar em produção:
pm2 start ecosystem.config.js --env production
pm2 save
pm2 startup    # gerar comando para iniciar no boot

# Reload sem downtime (reinicia instâncias uma a uma):
pm2 reload minha-api

# Monitorar em tempo real:
pm2 monit

Worker Threads para tarefas CPU-intensivas

Mover processamento pesado para threads separadas:

typescript
// worker.ts — tarefa CPU-intensiva
import { workerData, parentPort } from 'worker_threads'

function processarRelatorio(dados: unknown[]) {
  // processamento pesado (PDF, imagem, cálculos)
  return dados.length * 2
}

parentPort!.postMessage(processarRelatorio(workerData))

// api.ts — usar o worker
import { Worker } from 'worker_threads'
import path from 'path'

function executarWorker(dados: unknown[]): Promise<number> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(
      path.resolve(__dirname, 'worker.js'),
      { workerData: dados }
    )
    worker.on('message', resolve)
    worker.on('error', reject)
  })
}

app.post('/relatorio', async (req, res) => {
  // Não bloqueia o event loop principal:
  const resultado = await executarWorker(req.body.dados)
  res.json({ resultado })
})

Compressão e cache de respostas

Reduzir bytes trafegados e evitar reprocessamento:

typescript
import express from 'express'
import compression from 'compression'
import NodeCache from 'node-cache'

const app = express()
const cache = new NodeCache({ stdTTL: 60 }) // cache de 60s

// Compressão gzip/brotli (reduz payload 60-80%):
app.use(compression({
  threshold: 1024,     // comprimir apenas respostas > 1KB
  level: 6,            // nível de compressão (1-9, 6 é o balanço ideal)
}))

// Middleware de cache em memória para rotas pesadas:
function cacheMiddleware(ttl: number) {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const chave = req.originalUrl
    const cached = cache.get(chave)
    if (cached) {
      return res.json(cached)
    }
    const originalJson = res.json.bind(res)
    res.json = (body) => {
      cache.set(chave, body, ttl)
      return originalJson(body)
    }
    next()
  }
}

// Aplicar cache em rota pesada:
app.get('/api/relatorio-mensal', cacheMiddleware(300), async (req, res) => {
  const dados = await gerarRelatorio()  // executa apenas se não estiver no cache
  res.json(dados)
})

Profiling com Node.js --inspect e Clinic.js

Identificar gargalos de performance na aplicação:

bash
# Instalar Clinic.js (suite de profiling para Node.js):
npm install -g clinic

# Doctor: diagnóstico geral (I/O, event loop lag, memory):
clinic doctor -- node dist/index.js
# Acessar: http://localhost:3000 com carga e pressionar Ctrl+C

# Flame: flamegraph de CPU (onde o tempo está sendo gasto):
clinic flame -- node dist/index.js

# Bubbleprof: análise do event loop e async/await:
clinic bubbleprof -- node dist/index.js

# Gerar carga durante o profiling:
# Em outro terminal:
npx autocannon -c 100 -d 30 http://localhost:3000/api/endpoint

# Profiling nativo do Node.js (sem ferramentas externas):
node --prof dist/index.js &
# Gerar carga...
# Analisar:
node --prof-process isolate-*.log > processed.txt
cat processed.txt | head -100

Variáveis de ambiente e configuração de produção

Configurações do Node.js que impactam performance:

bash
# Variáveis de ambiente importantes para produção:

# Habilitar otimizações do V8:
NODE_ENV=production

# Aumentar memória disponível (padrão é ~1.5GB):
NODE_OPTIONS=--max-old-space-size=4096

# UV_THREADPOOL_SIZE: threads para I/O assíncrono (padrão: 4)
# Aumentar para workloads com muito I/O simultâneo:
UV_THREADPOOL_SIZE=16

# ecosystem.config.js com variáveis:
module.exports = {
  apps: [{
    name: 'api',
    script: './dist/index.js',
    instances: 'max',
    exec_mode: 'cluster',
    node_args: '--max-old-space-size=512',  // por instância
    env_production: {
      NODE_ENV: 'production',
      UV_THREADPOOL_SIZE: 16,
    },
  }],
}

# Verificar event loop lag em produção:
# Adicionar ao código:
import { monitorEventLoopDelay } from 'perf_hooks'
const h = monitorEventLoopDelay({ resolution: 20 })
h.enable()
setInterval(() => {
  console.log('Event loop lag (p99):', h.percentile(99) / 1e6, 'ms')
}, 30_000)

Não quer configurar manualmente?

Implante o VPS para Node.js em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

instances: "max" usa uma instância por CPU, que é o ponto ótimo para workloads CPU-bound. Para APIs I/O-bound (a maioria): instances: "max" ainda é ideal. Se suas instâncias consumem muita memória: defina um número fixo (ex: instances: 2) para evitar OOM. Monitore com pm2 monit para ver o uso de RAM por instância.

PM2 cluster: mais simples, processo único gerenciado, zero-downtime reload nativo. Nginx upstream: mais controle (weight, health checks, backup servers), necessário quando você tem instâncias em servidores diferentes. Para deploy em uma única VPS: PM2 cluster é a escolha óbvia. Para múltiplas VPSs: Nginx load balancer.

Worker Threads: compartilham memória (SharedArrayBuffer), ideal para tarefas CPU-intensivas como parsear JSON grande, gerar PDF, processar imagem. child_process.fork(): processo separado com próprio heap, ideal para isolamento total ou quando a tarefa precisa de um módulo incompatível com Workers. Para processamento paralelo de dados: Worker Threads. Para isolamento: child_process.

Para APIs JSON consumidas por clientes programáticos: diferença mínima. Para APIs consumidas por browsers (SSE, streaming, muitos assets): HTTP/2 multiplexing reduz latência significativamente. O HTTP/2 é mais vantajoso no Nginx (entre browser e Nginx), entre Nginx e Node.js (proxied): HTTP/1.1 com keep-alive é suficiente.

pm2 monit mostra uso de memória por instância em tempo real. Se uma instância cresce indefinidamente: memory leak. Heapdump em produção: process.kill(process.pid, "SIGUSR2") com heapdump instalado. Clinic.js doctor detecta padrões de memory leak automaticamente. Para análise: carregar o .heapsnapshot no Chrome DevTools → Memory.