Performance

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

O desempenho de um sistema é decidido no desenho. Quatro escolhas respondem pela maior parte do resultado: como a listagem pagina, o que entra em cache (armazenamento temporário de respostas), o que sai da requisição e vai para segundo plano, e o que só carrega quando alguém precisa (lazy loading · carregamento sob demanda).

Sob carga real, essas quatro decisões determinam se o sistema escala ou trava.

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

Devolver a tabela inteira em uma resposta inutiliza um endpoint (ponto de acesso da API (Application Programming Interface · Interface de Programação de Aplicações)) em produção. A tabela cresce, o tempo de resposta cresce junto, e o cliente gasta memória processando registros que ninguém 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

O offset/limit (paginação por deslocamento e quantidade) falha quando os dados mudam durante a navegação. Se um registro é inserido ou removido entre uma página e a seguinte, a numeração muda, e o usuário vê o mesmo item duas vezes ou deixa de ver um item. O cursor resolve isso porque ancora a próxima busca no ID ou no timestamp (registro de data e hora) do último item entregue.

A resposta paginada leva os dados e os metadados de navegação juntos: total de registros, próxima página ou próximo cursor. Assim o cliente descobre se ainda há dados sem fazer uma segunda chamada só para perguntar.

Cache

O cache entrega uma resposta guardada em vez de calcular tudo de novo. O ganho é direto: menos consulta ao banco, menos CPU (Central Processing Unit · Unidade Central de Processamento), menos espera. O risco também: um dado desatualizado chega ao cliente como se fosse atual.

A decisão que equilibra os dois lados é o TTL (Time To Live · tempo de vida), o prazo de validade da resposta guardada. TTL curto mantém o dado atual e devolve carga ao banco. TTL longo alivia o banco e aumenta a chance de servir informação vencida.

EstratégiaComo funcionaQuando usar
Cache-asideApp verifica cache → miss (ausência no cache) → busca no banco → armazenaLeituras frequentes, escrita infrequente
Write-throughEscrita vai ao banco e ao cache ao mesmo tempoConsistência alta, latência de escrita aceitável
Invalidação por eventoCache limpo quando dado mudaDado crítico que não pode ser obsoleto

O dado que muda com frequência e custa caro quando sai errado (saldo, estoque, status de pedido) só entra em cache com invalidação ativa. O dado estático ou de baixa criticidade (lista de países, configuração de layout) é candidato natural a TTL longo.

Fila e processamento em segundo plano

A operação lenta que roda dentro de uma requisição HTTP (HyperText Transfer Protocol · Protocolo de Transferência de Hipertexto) aumenta a espera do usuário e ocupa o worker (processo que executa tarefas em segundo plano) até terminar. Enviar e-mail, gerar relatório, redimensionar imagem, chamar um serviço externo: nenhuma dessas operações precisa segurar a resposta.

O padrão é sempre o mesmo: aceitar o trabalho, responder na hora e processar depois.

Request → persiste job → 202 AcceptedWorker processa → notifica resultado

Isso traz três ganhos diretos: o tempo de resposta fica previsível, a falha do job fica isolada no job (a requisição do usuário continua funcionando) e a nova tentativa acontece de forma automática nas falhas passageiras.

Quando usar fila:

  • A operação passa de uns 500ms
  • Ela depende de um serviço externo cujo SLA (Service Level Agreement · Acordo de Nível de Serviço) varia
  • Ela pode falhar e vai precisar de nova tentativa
  • O volume cria picos que o banco não absorve em tempo real

Webhook

No webhook (notificação HTTP que o servidor envia ao cliente quando o job termina), o servidor chama o cliente assim que o trabalho conclui, e o cliente não precisa perguntar.

Worker conclui job → POST <endpoint-do-cliente>Cliente responde 200 OK
PráticaMotivo
ID do job no payloadIdempotência: a reentrega da mesma notificação não duplica o efeito
Assinar com HMAC (Hash-based Message Authentication Code · código de autenticação de mensagem por hash)Prova que a chamada veio do servidor esperado
Nova tentativa com backoff exponencial (espera crescente entre tentativas)Absorve a falha passageira sem sobrecarregar o cliente
Registrar todas as tentativasAuditoria e diagnóstico da entrega

Quando usar: o cliente tem um endpoint público, e o job pode levar minutos ou horas.

Polling

No polling (consulta periódica ao servidor), o cliente pergunta o status do job de tempos em tempos até a resposta ficar pronta.

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

A vantagem é que o cliente não precisa expor endpoint nenhum. O custo é a carga desnecessária: quase toda consulta responde que o job ainda está em processamento.

O long polling (consulta que segura a conexão aberta até ter resposta ou até estourar o tempo limite) reduz esse custo. O servidor só responde quando tem dado novo, e o cliente reconecta assim que recebe.

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

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

Carregamento sob demanda

Carregar dados antes de precisar deles gasta recursos e aumenta o tempo de inicialização. O lazy loading adia o carregamento para o momento do uso.

ContextoAplicação
Banco de dadosRelacionamentos carregados no momento do acesso, fora do 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 conhecido é o N+1: carregar uma lista de 100 itens e disparar uma query (consulta ao banco) para cada item ao acessar um relacionamento. São 101 queries onde 2 bastariam. A solução é carregar os relacionamentos em lote quando o acesso é previsível.

Banco de dados

Índices, ajuste de queries, plano de execução e diagnóstico de gargalos estão em database.md.

Complexidade algorítmica (Big O)

O Big O descreve como o tempo de execução ou o uso de memória de um algoritmo cresce conforme a entrada cresce. Ele permite 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 o tempo real antes de otimizar; sem medição, a otimização vira palpite
  • 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

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.