Performance

Escopo: transversal. Aplica-se a qualquer linguagem ou stack do projeto.

Performance é uma decisão de design. As escolhas de paginação, cache (armazenamento temporário de respostas), processamento assíncrono e lazy loading determinam se o sistema escala ou trava sob carga real.

Conceitos fundamentais

ConceitoO que é
Cache (armazenamento temporário)Resposta armazenada para evitar recomputação ou nova consulta ao banco
TTL (Time To Live, tempo de vida)Tempo durante o qual uma entrada em cache é considerada válida
Offset/limit (paginação por deslocamento e quantidade)Modelo de paginação que pula N registros e retorna os próximos M
Cursor (ponteiro de paginação)Referência ao último item retornado, usada para paginação estável em dados que mudam
Lazy loading (carregamento sob demanda)Carregar dados ou código apenas no momento em que são necessários
N+1 (consulta repetida em loop, anti-padrão)Anti-padrão que executa uma query por item de uma lista em vez de uma única query em lote
Connection pooling (agrupamento de conexões)Reutilização de conexões abertas com o banco para reduzir o custo de handshake por requisição
I/O (Input/Output, entrada/saída)Operações que leem ou escrevem em sistemas externos: banco, rede, disco
Big O (notação de complexidade assintótica)Notação que descreve como o tempo ou espaço de um algoritmo cresce em função do tamanho da entrada
Time complexity (complexidade de tempo)Quantas operações o algoritmo executa em relação ao tamanho da entrada
Space complexity (complexidade de espaço)Quanta memória o algoritmo usa em relação ao tamanho da entrada

Paginação

Retornar todos os registros de uma tabela numa única resposta é a forma mais rápida de tornar um endpoint (ponto de acesso da API (Application Programming Interface, Interface de Programação de Aplicações)) inutilizável em produção. A quantidade de dados cresce, o tempo de resposta cresce junto, e o cliente precisa processar mais do que vai usar.

Dois modelos cobrem a maioria dos casos:

ModeloComo funcionaMelhor para
Offset/limitLIMIT 20 OFFSET 40 (pula N registros)Listagens com navegação por página
CursorReferência ao último item retornadoFeeds infinitos, dados que mudam frequentemente

Offset/limit (paginação por deslocamento e quantidade) tem um problema em dados mutáveis: se um registro é inserido ou removido entre páginas, a numeração muda e itens aparecem duplicados ou somem. Cursor evita isso usando o ID ou timestamp (registro de data e hora) do último item como âncora.

A resposta de uma lista paginada inclui os dados e metadados de navegação: total de registros, próxima página ou próximo cursor. O cliente nunca precisa fazer uma segunda chamada só para saber se há mais dados.

Cache

Cache serve uma resposta armazenada em vez de recomputar. O benefício é direto: menos banco, menos CPU (Central Processing Unit, unidade de processamento), menos latência. O risco também: dados desatualizados chegando ao cliente como se fossem frescos.

A decisão central é o TTL (Time To Live, tempo de vida): por quanto tempo a resposta armazenada é considerada válida. TTL curto = cache quente mas dado fresco. TTL longo = menos pressão no banco, dado potencialmente obsoleto.

EstratégiaQuando usar
Cache-asideApp verifica cache → miss (ausência no cache) → busca no banco → armazena
Write-throughEscrita vai ao banco e ao cache ao mesmo tempo
Invalidação por eventoCache limpo quando dado muda

Dados que mudam frequentemente com custo de desatualização alto (saldo, estoque, status de pedido) não devem ser cacheados sem invalidação ativa. Dados estáticos ou de baixa criticidade (listas de países, configurações de layout) são candidatos naturais a TTL longo.

Fila e Processamento Assíncrono

Operações lentas dentro de uma requisição HTTP (HyperText Transfer Protocol, Protocolo de Transferência de Hipertexto) aumentam a latência percebida pelo usuário e travam o worker (trabalhador) enquanto esperam. Envio de e-mail, geração de relatório, resize de imagem, integração com serviço externo: nenhuma dessas operações precisa bloquear a resposta.

O padrão é: aceitar o trabalho, responder imediatamente, processar em background.

Request → persiste job → 202 AcceptedWorker processa → notifica resultado

Benefícios diretos: tempo de resposta previsível, isolamento de falhas (job falha sem derrubar o request), retry automático em falhas transitórias.

Quando usar fila:

  • Operação leva mais de ~500ms
  • Depende de serviço externo com SLA (Service Level Agreement, Acordo de Nível de Serviço) variável
  • Pode falhar e precisa de retry
  • Volume pode criar picos que o banco não absorve em tempo real

Webhook

Webhook (notificação HTTP enviada pelo servidor ao cliente quando o job conclui) elimina o polling: o servidor chama o cliente, sem o cliente precisar perguntar.

Worker conclui job → POST <endpoint-do-cliente>Cliente responde 200 OK
PráticaMotivo
ID do job no payloadIdempotência: reentregas não duplicam efeito
Assinar com HMAC (Hash-based Message Authentication Code, código de autenticação de mensagem por hash)Valida que a chamada veio do servidor esperado
Retry com backoff (espera crescente entre tentativas) exponencialAbsorve falhas transitórias sem sobrecarregar o cliente
Registrar todas as tentativasAuditoria e diagnóstico de entrega

Quando usar: cliente expõe endpoint público, job pode levar minutos ou horas.

Polling

Polling (consulta periódica ao servidor) é o cliente verificando o status do job em intervalos regulares até a resposta estar pronta.

GET /jobs/{id}/status → 202 In ProgressGET /jobs/{id}/status → 200 Done + resultado

Sem dependência de endpoint no cliente. O custo é carga desnecessária: a maioria das consultas retorna "em processamento".

Long polling (polling que segura a conexão aberta até ter resposta ou o timeout expirar) reduz esse custo. O servidor só responde quando há dado novo. O cliente reconecta imediatamente após receber.

ModeloIntervaloImpacto
Short pollingFixo (ex: 2s)Simples, cria carga mesmo sem mudança de estado
Long pollingServidor decideMenos requests, maior complexidade no servidor

Quando usar: cliente sem endpoint público, processamento de duração previsível e curta.

WebSocket

WebSocket (canal bidirecional persistente entre cliente e servidor) mantém uma conexão aberta. O servidor envia o resultado quando o job conclui, sem o cliente perguntar.

Cliente conecta → handshake → [conexão ativa]Servidor envia resultado → Cliente processa

Menor latência entre as três opções: o resultado chega assim que disponível, sem intervalo de polling e sem overhead (custo extra) de nova conexão HTTP.

O custo é operacional: cada cliente conectado mantém uma conexão aberta no servidor. Gateway, load balancer (balanceador de carga) e infra precisam suportar conexões persistentes, o que afeta o escalonamento horizontal.

Quando usar: UI (User Interface, Interface do Usuário) em tempo real, dashboards (painéis ao vivo) e feeds ao vivo, onde a latência mínima justifica a complexidade operacional.

Lazy Loading

Carregar dados antes de precisar deles desperdiça recursos e aumenta o tempo de inicialização. Lazy loading (carregamento sob demanda) adia o carregamento para o momento do uso.

ContextoAplicação
Banco de dadosRelacionamentos carregados apenas quando acessados, não no join inicial
FrontendComponentes e imagens carregados conforme entram no viewport (área visível da tela)
MódulosCódigo importado só quando o fluxo de execução chega até ele

O risco clássico é o N+1: carregar uma lista de 100 itens e fazer uma query (consulta ao banco) para cada item ao acessar um relacionamento. O resultado são 101 queries em vez de 2. A solução é carregar relacionamentos em lote quando o acesso é previsível.

Banco de Dados

Índices, tuning de queries, plano de execução e troubleshooting de gargalos estão em database.md.

Complexidade Algorítmica (Big O)

Big O descreve como o tempo de execução ou o uso de memória de um algoritmo cresce à medida que a entrada cresce. É a ferramenta para avaliar se uma solução escala antes de medir em produção.

NotaçãoComportamentoExemplo prático
O(1)Constante, não cresce com a entradaAcesso a elemento de array por índice, lookup em hash map
O(log n)Logarítmica, cresce devagarBusca binária em array ordenado
O(n)Linear, cresce com a entradaIterar uma lista uma vez
O(n log n)LinearítmicaOrdenação eficiente (mergesort, quicksort no caso médio)
O(n²)Quadrática, cresce muito rápidoLoop aninhado sobre a mesma coleção
O(2ⁿ)Exponencial, inviável para n grandeSubconjuntos recursivos sem memoization (armazenamento de resultados intermediários)

A regra prática: O(n²) é o limite onde a maioria dos problemas de escala começa. Qualquer loop aninhado sobre a mesma coleção é um candidato a revisão.

Armadilhas comuns

Loop aninhado sobre a mesma coleção

O caso mais frequente de O(n²) oculto. Para cada item externo, itera todos os itens internos.

❌ Ruim: O(n²) com loop aninhado sobre a mesma coleção
for (const order of orders) {
  for (const item of orders) {
    if (order.id === item.relatedId) { ... }
  }
}
✅ Bom: indexar em O(n), acessar em O(1)
function findRelatedOrders(orders) {
  const orderIndex = new Map(orders.map(order => [order.id, order]));

  const ordersWithRelated = orders.map(order => ({
    ...order,
    related: orderIndex.get(order.relatedId),
  }));

  return ordersWithRelated;
}

N+1 queries no banco de dados

Carregar uma lista e fazer uma query para cada item. O(n) queries em vez de O(1).

❌ Ruim: N+1, uma query por item da lista
const orders = await orderRepository.findAll();

for (const order of orders) {
  order.customer = await customerRepository.findById(order.customerId);
}
✅ Bom: duas queries no total com busca em lote
async function loadOrdersWithCustomers() {
  const orders = await orderRepository.findAll();
  const customerIds = orders.map(order => order.customerId);

  const customers = await customerRepository.findByIds(customerIds);
  const customerIndex = new Map(customers.map(customer => [customer.id, customer]));

  const ordersWithCustomers = orders.map(order => ({
    ...order,
    customer: customerIndex.get(order.customerId),
  }));

  return ordersWithCustomers;
}

Múltiplas iterações desnecessárias

Encadeamento de .filter().map() quando uma única passagem resolve.

❌ Ruim: dois passes sobre a mesma lista
const activeUserNames = users
  .filter(user => user.isActive)
  .map(user => user.name);
✅ Bom: um passe com reduce quando o volume importa
function extractActiveUserNames(users) {
  const activeUserNames = users.reduce((names, user) => {
    if (user.isActive) names.push(user.name);
    return names;
  }, []);

  return activeUserNames;
}

Para listas pequenas (< alguns milhares de itens), .filter().map() é legível e aceitável. O impacto de dois passes só é relevante em volumes grandes ou loops internos de hot paths (caminhos de código executados com altíssima frequência).

Ordenação desnecessária

Array.sort() é O(n log n). Se o objetivo é encontrar o máximo ou mínimo, uma iteração linear O(n) resolve.

❌ Ruim: sort() para obter o maior valor (O(n log n))
const highestScore = scores.sort((a, b) => b - a)[0];
✅ Bom: Math.max() em O(n)
function findHighestScore(scores) {
  const highestScore = Math.max(...scores);
  return highestScore;
}

Como identificar

  • Code review: qualquer loop dentro de outro loop sobre a mesma coleção é suspeito
  • Query count logging: logar o número de queries por request revela N+1 rapidamente
  • Profiler: medir tempo real antes de otimizar; otimização sem medição é especulação
  • Critério de aceitação: para operações em lote ou relatórios, definir na spec o volume esperado e o tempo máximo aceitável

Desenvolvido por @thiagocajadev · Fork baseado no repositório pmndrs/docs · Poimandres.