Node.js com múltiplos CPUs: cluster e worker_threads

Node.js é single-threaded por design, um processo usa apenas 1 vCPU. Em VPS com 2 ou 4 vCPUs, você paga por recursos que a aplicação não usa. Módulo cluster cria múltiplos processos worker para apps HTTP; worker_threads paraleliza operações CPU-intensivas dentro do mesmo processo. Saber qual usar dobra ou quadruplica o throughput sem trocar de servidor.

Módulo cluster: múltiplos processos HTTP

O módulo cluster do Node.js cria um processo master que distribui conexões TCP entre workers, cada worker é um processo Node.js completo rodando em seu próprio vCPU:

typescript
// src/cluster.ts
import cluster from 'cluster'
import { availableParallelism } from 'os'
import process from 'process'

const NUM_WORKERS = availableParallelism() // número de vCPUs

if (cluster.isPrimary) {
  console.log(`Master ${process.pid} rodando. Iniciando ${NUM_WORKERS} workers...`)

  // Criar um worker por vCPU
  for (let i = 0; i < NUM_WORKERS; i++) {
    cluster.fork()
  }

  // Reiniciar worker morto automaticamente
  cluster.on('exit', (worker, code, signal) => {
    console.warn(`Worker ${worker.process.pid} morreu (code:${code}). Reiniciando...`)
    cluster.fork()
  })
} else {
  // Este código roda em cada worker
  import('./server').then(({ startServer }) => {
    startServer()
    console.log(`Worker ${process.pid} rodando`)
  })
}

worker_threads: paralelismo CPU-bound

Worker threads rodam em threads do mesmo processo, compartilhando memória via SharedArrayBuffer, ideal para cálculos pesados que travariam o event loop:

typescript
// src/workers/hash-worker.ts
import { workerData, parentPort } from 'worker_threads'
import crypto from 'crypto'

// Esta função roda em uma thread separada
function hashIntensivo(dados: string): string {
  let resultado = dados
  for (let i = 0; i < 100000; i++) {
    resultado = crypto.createHash('sha256').update(resultado).digest('hex')
  }
  return resultado
}

parentPort?.postMessage(hashIntensivo(workerData.input))

// src/services/hash.service.ts
import { Worker } from 'worker_threads'
import path from 'path'

export function hashComWorker(input: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(
      path.join(__dirname, 'workers/hash-worker.js'),
      { workerData: { input } }
    )

    worker.on('message', resolve)
    worker.on('error', reject)
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker encerrou com código ${code}`))
    })
  })
}

// Uso em rota Express (não bloqueia o event loop)
app.post('/hash', async (req, res) => {
  const resultado = await hashComWorker(req.body.input)
  res.json({ hash: resultado })
})

Pool de worker_threads: reutilizar threads

Criar uma nova thread por requisição é caro. Um pool reutiliza threads para múltiplas tarefas:

typescript
// npm install piscina (pool de worker_threads)
import Piscina from 'piscina'
import path from 'path'

const pool = new Piscina({
  filename: path.join(__dirname, 'workers/processamento.js'),
  minThreads: 2,
  maxThreads: 4,      // máximo de threads no pool
  idleTimeout: 30000, // remover threads ociosas após 30s
})

// Usar o pool (espera uma thread disponível)
app.post('/processar', async (req, res) => {
  try {
    const resultado = await pool.run({ dados: req.body.dados })
    res.json(resultado)
  } catch (err) {
    res.status(500).json({ error: err.message })
  }
})

// Monitorar o pool
console.log('Threads ativas:', pool.threads.length)
console.log('Tarefas na fila:', pool.queueSize)

Quando usar cluster vs worker_threads

A escolha depende do tipo de operação que você quer paralelizar:

bash
# cluster mode — use para:
# ✓ APIs HTTP que recebem muitas requisições simultâneas (I/O-bound)
# ✓ Cada worker processa requisições independentes
# ✓ Isolamento: se um worker travar, os outros continuam
# ✓ Compatível com PM2 --cluster

# worker_threads — use para:
# ✓ Operações CPU-intensivas dentro de uma requisição
#   (resize de imagem, criptografia, parsing de arquivo grande)
# ✓ Cálculos numéricos (ML inference, compressão)
# ✓ Processamento paralelo de arrays grandes
# ✓ Compartilhamento de memória via SharedArrayBuffer

# Regra geral:
# "Muitas requisições pequenas" → cluster (PM2)
# "Uma requisição com processamento pesado" → worker_threads

# Métricas para decidir:
# CPU do processo Node.js > 80%? → worker_threads para offload
# Latência alta mesmo com CPU baixo? → I/O wait, não precisa de parallelism

Detectar bloqueio do event loop

Event loop bloqueado é o maior problema de performance em Node.js. Detecte com métricas de lag:

typescript
// Medir lag do event loop (operação que NÃO deve demorar)
function medirEventLoopLag(): Promise<number> {
  return new Promise((resolve) => {
    const inicio = Date.now()
    setImmediate(() => {
      resolve(Date.now() - inicio) // deve ser < 5ms em condições normais
    })
  })
}

// Monitorar continuamente
setInterval(async () => {
  const lag = await medirEventLoopLag()
  if (lag > 100) {
    logger.warn('Event loop lag alto', { lag_ms: lag })
    // Alerta: CPU-bound bloqueando o loop
  }
}, 5000)

// Alternativa: usar clinic.js para diagnóstico detalhado
// npm install -g clinic
// clinic doctor -- node dist/index.js
// Gera relatório HTML com gargalos identificados

Não quer configurar manualmente?

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

Perguntas frequentes

Node.js tem limite padrão de 1.5 GB de heap (V8). Para aumentar: node --max-old-space-size=4096 dist/index.js (4 GB). Em Docker: CMD ["node", "--max-old-space-size=4096", "dist/index.js"]. Com cluster, cada worker tem seu próprio limite, em 4 workers com 2 GB cada: processo total de ~8 GB de memória heap.

Sim, indiretamente. Com 1 worker, requisições são processadas uma a vez (event loop single-thread). Com 4 workers, 4 queries podem ir para o banco simultaneamente, aumenta o throughput total mas não a latência de uma query individual. O banco (PostgreSQL) é o gargalo real em apps CRUD-heavy.

Workers do cluster são processos separados, não compartilham memória. Para compartilhar estado: (1) Redis como cache externo (mais comum); (2) IPC (inter-process communication) via cluster.worker.send(); (3) SharedArrayBuffer via worker_threads para dados binários de baixa latência. Para cache simples: Redis com setex é a solução padrão.

clinic doctor (npm i -g clinic) é o diagnóstico mais completo, gera relatório HTML com flame graph. Para diagnóstico imediato em produção: kill -USR2 PID gera um heapsnapshot que pode ser analisado no Chrome DevTools. node --prof dist/index.js + node --prof-process isolate-*.log gera profile de CPU.

Nunca use ts-node em produção, a transpilação é feita em runtime, adicionando overhead de CPU e memória. Sempre compile com tsc (ou esbuild/swc) para JavaScript antes de deploy: npm run build gera dist/. Em desenvolvimento: ts-node-dev ou tsx para hot reload. Em produção: node dist/index.js sobre o JavaScript compilado.