Pular para o conteúdo
upgbp

Prisma, Drizzle ou postgres.js: medindo o que importa em serverless

A maioria dos benchmarks de ORM mede a coisa errada. O que pesa em função efêmera é a primeira query, não a milésima — e o script para medir isso.

6 min de leitura

Ambiente testado

  • Node 22.14
  • Prisma 6.5
  • Drizzle 0.44
  • postgres.js 3.4
Neste artigo (9)

Contexto: a pergunta errada

“Qual ORM é mais rápido?” é a pergunta que gera comparações inúteis. A maioria dos benchmarks que circula mede um laço de dez mil consultas contra um Postgres local, no mesmo processo, já aquecido.

Nenhuma dessas condições existe em produção serverless. Lá, o cenário comum é: processo novo, conexão nova, uma consulta, e o processo morre. A milésima consulta nunca acontece.

O que medir

Cinco números, em ordem de importância para função efêmera:

Métrica Por que importa
Tempo de import Roda em toda invocação fria, antes de qualquer linha sua
Inicialização do cliente Alguns drivers montam estruturas caras aqui
Primeira consulta Inclui abrir conexão e negociar o protocolo
Consulta aquecida (p50/p95) O que acontece quando a instância é reaproveitada
Memória após inicializar Define o tamanho de função que você precisa contratar

Medir só a quinta e a quarta — que é o que benchmarks tradicionais fazem — esconde justamente o que dói.

O script

Cada driver roda em processo separado, porque medir import no mesmo processo contamina o segundo com o cache de módulos do primeiro.

scripts/bench.mjs
import { spawnSync } from 'node:child_process';

const ALVOS = ['prisma', 'drizzle', 'postgres-js'];
const REPETICOES = 10;

const resultados = {};

for (const alvo of ALVOS) {
  const amostras = [];
  for (let i = 0; i < REPETICOES; i++) {
    // Processo novo a cada repetição: é o que reproduz o cold start.
    const proc = spawnSync('node', ['scripts/bench-alvo.mjs', alvo], {
      encoding: 'utf8',
      env: process.env,
    });
    if (proc.status !== 0) {
      console.error(proc.stderr);
      process.exit(1);
    }
    amostras.push(JSON.parse(proc.stdout));
  }
  resultados[alvo] = resumir(amostras);
}

function percentil(valores, p) {
  const ordenado = [...valores].sort((a, b) => a - b);
  return ordenado[Math.min(ordenado.length - 1, Math.floor(ordenado.length * p))];
}

function resumir(amostras) {
  const campo = (nome) => amostras.map((a) => a[nome]);
  const quentes = amostras.flatMap((a) => a.quentes);
  return {
    importMs: Math.round(percentil(campo('importMs'), 0.5)),
    initMs: Math.round(percentil(campo('initMs'), 0.5)),
    primeiraMs: Math.round(percentil(campo('primeiraMs'), 0.5)),
    quenteP50: Math.round(percentil(quentes, 0.5) * 100) / 100,
    quenteP95: Math.round(percentil(quentes, 0.95) * 100) / 100,
    rssMb: Math.round(percentil(campo('rssMb'), 0.5)),
  };
}

console.table(resultados);
scripts/bench-alvo.mjs
const alvo = process.argv[2];
const URL = process.env.BENCH_DATABASE_URL;

const t0 = performance.now();
let cliente, consultar;

if (alvo === 'prisma') {
  const { PrismaClient } = await import('@prisma/client');
  const importMs = performance.now() - t0;
  const t1 = performance.now();
  cliente = new PrismaClient();
  var initMs = performance.now() - t1;
  consultar = () => cliente.produto.findMany({ where: { ativo: true }, take: 20 });
  var tempoImport = importMs;
} else if (alvo === 'drizzle') {
  const [{ drizzle }, { default: postgres }, schema] = await Promise.all([
    import('drizzle-orm/postgres-js'),
    import('postgres'),
    import('../src/db/schema.js'),
  ]);
  var tempoImport = performance.now() - t0;
  const t1 = performance.now();
  const sql = postgres(URL, { prepare: false, max: 1 });
  cliente = drizzle(sql, { schema });
  var initMs = performance.now() - t1;
  const { eq } = await import('drizzle-orm');
  consultar = () => cliente.select().from(schema.produtos)
    .where(eq(schema.produtos.ativo, true)).limit(20);
} else {
  const { default: postgres } = await import('postgres');
  var tempoImport = performance.now() - t0;
  const t1 = performance.now();
  const sql = postgres(URL, { prepare: false, max: 1 });
  var initMs = performance.now() - t1;
  cliente = sql;
  consultar = () => sql`select * from produtos where ativo = true limit 20`;
}

// Primeira consulta: inclui abrir a conexão TCP e o handshake do protocolo.
const t2 = performance.now();
await consultar();
const primeiraMs = performance.now() - t2;

// Consultas aquecidas: conexão já aberta.
const quentes = [];
for (let i = 0; i < 50; i++) {
  const t = performance.now();
  await consultar();
  quentes.push(performance.now() - t);
}

console.log(JSON.stringify({
  importMs: tempoImport,
  initMs,
  primeiraMs,
  quentes,
  rssMb: process.memoryUsage().rss / 1024 / 1024,
}));

process.exit(0);

Todos consultam a mesma tabela, com o mesmo filtro, o mesmo limit e as mesmas colunas. Comparar findMany com um select * que traz o dobro das colunas mede a diferença entre as consultas, não entre as bibliotecas.

Os resultados

node scripts/bench.mjs
┌─────────────┬──────────┬────────┬────────────┬───────────┬───────────┬───────┐
│ (index)     │ importMs │ initMs │ primeiraMs │ quenteP50 │ quenteP95 │ rssMb │
├─────────────┼──────────┼────────┼────────────┼───────────┼───────────┼───────┤
│ prisma      │ 312      │ 84     │ 141        │ 2.31      │ 4.88      │ 168   │
│ drizzle     │ 96       │ 4      │ 128        │ 2.04      │ 4.12      │ 74    │
│ postgres-js │ 61       │ 3      │ 124        │ 1.88      │ 3.90      │ 66    │
└─────────────┴──────────┴────────┴────────────┴───────────┴───────────┴───────┘

O que os números dizem

A consulta aquecida é praticamente empate. A diferença entre 1,88 ms e 2,31 ms é ruído diante de qualquer chamada de rede. Se alguém escolher ORM por esta coluna, escolheu por nada.

A primeira consulta também empata. Faz sentido: ela é dominada por abrir a conexão TCP e negociar o protocolo, trabalho que os três delegam ao mesmo Postgres.

A diferença real está no import e na inicialização. Prisma somou cerca de 400 ms antes da primeira linha de lógica rodar. Isso é cold start puro — o usuário espera, e a função é cobrada por esse tempo.

Memória separa as opções por classe de função. 168 MB contra 66 MB decide se cabe numa função de 128 MB ou se você precisa contratar mais.

O que os números não dizem

Nada disso mede o que mais custa num projeto: migrations com histórico confiável, tipos gerados que pegam erro em tempo de compilação, e a facilidade de alguém novo entender uma consulta em seis meses.

Prisma paga cold start e devolve o melhor conjunto de ferramentas de esquema. Drizzle fica no meio: SQL explícito com tipos, migrations razoáveis e custo baixo. postgres.js puro é o mais leve e o que exige mais disciplina de quem escreve.

Como decidir

Situação Escolha que costuma vencer
Função invocada raramente, cold start visível postgres.js ou Drizzle
API constantemente quente, instâncias reaproveitadas Qualquer um; decida por ergonomia
Esquema complexo, time grande, migrations frequentes Prisma
Edge runtime Drizzle ou postgres.js
Consultas analíticas complicadas SQL cru, com qualquer um deles por baixo

Como confirmar os seus números

Rode contra o banco real, na região real. Um Postgres em localhost responde em 0,2 ms; um gerenciado a uma região de distância responde em 40 ms. A segunda condição é a sua, e ela achata todas as diferenças de biblioteca.

Descarte a primeira execução. Cache de sistema de arquivos, JIT e resolução de DNS distorcem a primeira medição de qualquer processo.

Meça com o mesmo pooler. Comparar um driver na conexão direta com outro no pooler compara caminhos de rede, não bibliotecas.

Confira o peso do deploy também:

du -sh node_modules/@prisma node_modules/.prisma node_modules/drizzle-orm node_modules/postgres

O motor do Prisma é um binário nativo por plataforma, e ele conta no tamanho do pacote enviado — o que afeta o tempo de descompactação da função fria.

Armadilhas que sobram depois disso

Configuração muda o resultado mais que a biblioteca. Um connection_limit errado, statements preparados ligados atrás de pooler, ou região desalinhada custam mais que qualquer diferença medida aqui.

Prisma tem caminhos alternativos. Versões recentes oferecem adaptadores de driver e uma trilha sem o motor em Rust, o que altera bastante a coluna de import. Se essa coluna for decisiva para você, meça a configuração exata que pretende usar, não o padrão.

Drizzle herda o driver. Ele é uma camada fina; trocar postgres.js por pg por baixo muda os números. O que você mediu foi o par, não o Drizzle sozinho.

Consulta única esconde o N+1. Nenhum benchmark de consulta única mostra o problema que mais degrada aplicação real: uma consulta por item numa listagem. Isso é decidido pelo código, não pela biblioteca.

Benchmark envelhece. Estes números valem para as versões da faixa “Ambiente testado” no topo. Rode de novo antes de citá-los numa decisão.

Continue por aqui