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.
Ambiente testado
- Node 22.14
- Prisma 6.5
- Drizzle 0.44
- postgres.js 3.4
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.
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);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
┌─────────────┬──────────┬────────┬────────────┬───────────┬───────────┬───────┐
│ (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
Dados & Migrações
prepared statement s0 already exists: por que o pooler quebra
O erro só aparece depois que você troca a conexão direta pelo pooler em modo transaction. A causa está em como o Postgres guarda statements preparados.
Dados & Migrações
Prisma em serverless: resolvendo o timeout do connection pool
O pool do Prisma estoura quando cada função serverless abre o próprio conjunto de conexões. O diagnóstico completo e as três correções que aplicamos.
Dados & Migrações
RLS no Supabase: por que sua query retorna array vazio
Com Row Level Security ligada, o SELECT bloqueado não dá erro — devolve nada. Como depurar a política sem desligar a segurança para "testar".