Saltar para o conteúdo
Performance em APIs Node.js: Profiling e Otimização
Node.js

Performance em APIs Node.js: Profiling e Otimização

15 de novembro de 2024·Paulo de Paula

Antes de otimizar qualquer coisa, você precisa medir. “Minha API está lenta” não é um problema — é um sintoma. O problema pode ser CPU, memória, I/O ou até uma query mal indexada. Vamos destrinchar como encontrar e resolver cada um.

O arsenal de profiling

clinic.js — o ponto de partida

npm install -g clinic

# Detecta automaticamente o tipo de problema
clinic doctor -- node server.js

# Análise detalhada de CPU
clinic flame -- node server.js

# Perfil de I/O e delays de event loop
clinic bubbleprof -- node server.js

O clinic doctor vai te dizer se o problema é CPU-bound, I/O-bound ou event loop bloqueado. A partir daí você aprofunda.

V8 Profiler nativo

// Sem dependências externas
const v8Profiler = require('v8-profiler-next');
const fs = require('fs');

// Inicia o profiling
v8Profiler.startProfiling('API Profile', true);

// Após a janela que você quer analisar:
setTimeout(() => {
  const profile = v8Profiler.stopProfiling('API Profile');
  profile.export((error, result) => {
    fs.writeFileSync('profile.cpuprofile', result);
    profile.delete();
    // Abra no Chrome DevTools > Performance > Load Profile
  });
}, 30000);

Os gargalos mais comuns

1. JSON.parse em payloads grandes

// Lento para payloads > 100KB
app.post('/import', express.json({ limit: '10mb' }), (req, res) => {
  const data = req.body; // JSON.parse já aconteceu aqui
  processData(data);
});

// Melhor: streaming parser
import { createStream } from 'JSONStream';
import { pipeline } from 'stream/promises';

app.post('/import', async (req, res) => {
  const records = [];
  const jsonStream = createStream('records.*');
  
  jsonStream.on('data', (record) => records.push(record));
  
  await pipeline(req, jsonStream);
  processData(records);
  res.json({ imported: records.length });
});

2. Queries N+1 disfarçadas

// Causa 101 queries para 100 posts
const posts = await Post.findAll();
const result = await Promise.all(
  posts.map(async (post) => ({
    ...post.toJSON(),
    author: await User.findByPk(post.authorId), // N queries!
  }))
);

// Correto: eager loading
const posts = await Post.findAll({
  include: [{ model: User, as: 'author' }],
});
// 1 query com JOIN

3. Operações síncronas bloqueando o event loop

// Bloqueia o event loop inteiro enquanto computa
app.get('/report', (req, res) => {
  const result = heavyComputation(largeDataset); // BLOQUEANTE
  res.json(result);
});

// Melhor: worker threads para CPU-bound
import { Worker, isMainThread, parentPort } from 'worker_threads';

if (isMainThread) {
  app.get('/report', (req, res) => {
    const worker = new Worker(__filename, {
      workerData: { dataset: req.query.id }
    });
    worker.once('message', (result) => res.json(result));
  });
} else {
  const result = heavyComputation(workerData.dataset);
  parentPort.postMessage(result);
}

4. Memory leaks em closures

// Leak clássico: listeners nunca removidos
class DataProcessor extends EventEmitter {
  constructor() {
    super();
    // Toda instância adiciona um listener no processo
    process.on('SIGTERM', () => this.cleanup()); // LEAK!
  }
}

// Correto: guardar referência e remover
class DataProcessor extends EventEmitter {
  constructor() {
    super();
    this._cleanup = () => this.cleanup();
    process.on('SIGTERM', this._cleanup);
  }

  destroy() {
    process.off('SIGTERM', this._cleanup);
    this.removeAllListeners();
  }
}

Configurações de processo que fazem diferença

// .env ou configuração de deploy

// Aumenta o heap disponível (padrão ~1.5GB no Node 20)
NODE_OPTIONS=--max-old-space-size=4096

// Ativa a compilação antecipada (melhora cold start em Lambda)
NODE_OPTIONS=--enable-source-maps

// Cluster: usa todos os cores
import cluster from 'cluster';
import os from 'os';

if (cluster.isPrimary) {
  const numCPUs = os.cpus().length;
  for (let i = 0; i < numCPUs; i++) cluster.fork();
  cluster.on('exit', (worker) => cluster.fork()); // auto-restart
} else {
  startServer();
}

Monitoramento contínuo com métricas customizadas

import { Histogram, Counter } from 'prom-client';

const httpDuration = new Histogram({
  name: 'http_request_duration_ms',
  help: 'Duração das requisições HTTP',
  labelNames: ['method', 'route', 'status'],
  buckets: [5, 10, 25, 50, 100, 250, 500, 1000],
});

app.use((req, res, next) => {
  const end = httpDuration.startTimer();
  res.on('finish', () => {
    end({
      method: req.method,
      route: req.route?.path ?? 'unknown',
      status: res.statusCode,
    });
  });
  next();
});

// Expor para Prometheus/Grafana
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

A regra de ouro: meça primeiro, otimize depois. Um profiler aponta onde gastar energia. Intuição sobre performance quase sempre erra.