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.jsO 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.