Pular para o conteúdo
< samuelsantana.dev />
Voltar para o BlogDiagrama: instâncias Lambda com max 5 conexões convergem para o pooler, que abre poucas conexões reais com o Postgres.

Connection Pooling em Postgres Serverless: Por Que Sua API Trava em Produção

Samuel Santana
Publicado em 18 de setembro de 2026
PostgreSQLNodeJSNestJS

Funciona perfeitamente no seu localhost. Passa no CI. Funciona em staging com dois usuários de teste. Aí você sobe para produção, o tráfego cresce um pouco e a API começa a devolver too many connections ou, pior, prepared statement "s1" already exists, um erro que não faz sentido nenhum para quem nunca escreveu um PREPARE na vida.

Isso raramente é bug do seu código. É a física das conexões de banco colidindo com a física dos ambientes serverless, e a maioria dos tutoriais de Node com Postgres não fala disso.

Eu fiz essa conta quando a API deste blog, o vertex-api, foi para o AWS Lambda e passou a falar com o Postgres do Neon pelo endpoint com pooler. O que segue é o raciocínio que ficou registrado no código.

Conexão é cara, e serverless multiplica quem pede

Uma conexão Postgres não é um keep-alive de HTTP barato. Cada conexão é um processo no servidor do banco e ocupa memória real, e o Postgres tem um teto rígido, o max_connections. Em serviços gerenciados, esse teto acompanha o tamanho da máquina: num compute pequeno do plano gratuito do Neon, ele fica na casa das poucas centenas.

Num backend tradicional, você abre um pool de N conexões no boot do processo e reaproveita. Um processo, um pool, tudo previsível.

No serverless, cada instância abre o próprio pool. No Lambda padrão (sem Managed Instances), cada ambiente de execução atende uma requisição por vez e guarda o pool congelado entre invocações. Com 50 ambientes simultâneos, cada um com um pool de 10 conexões porque esse é o padrão do postgres.js, você acabou de autorizar até 500 conexões ao banco. O mesmo vale, em escala menor, para um NestJS com várias réplicas escalando na horizontal.

A conta: (máximo de instâncias simultâneas) × (tamanho do pool por instância) tem de ficar abaixo do max_connections do banco, com margem. Se você nunca fez essa conta, provavelmente vai estourar o limite num pico de tráfego que ainda não aconteceu.

Por que existe um pooler na frente do banco

A resposta padrão é a aplicação não falar direto com o Postgres, e sim com um connection pooler. O PgBouncer é o mais conhecido; Neon e Supabase oferecem um gerenciado, em geral num endpoint separado. No Neon, é o hostname com o sufixo -pooler.

O pooler mantém poucas conexões reais com o Postgres e multiplexa centenas de conexões lógicas da aplicação por cima delas. Para a API, parece que as conexões são ilimitadas; na prática, o pooler faz malabarismo por trás. E a conta da seção anterior muda de lugar: instâncias × max passa a esbarrar no limite de clientes do pooler, que é muito maior, e o limite físico passa a ser o pool do próprio pooler.

O detalhe que pega todo mundo está no modo de pooling:

  • Session mode: a conexão do cliente fica presa a uma conexão real até ele desconectar. Parece conexão direta e não resolve a escala.
  • Transaction mode: a conexão real só pertence à sua sessão durante uma transação. Quando ela termina, a conexão volta para o pool e pode servir outro cliente. É o modo do endpoint -pooler do Neon, porque é o que permite multiplexar de verdade.

Transaction mode tem um preço: o que vive na sessão, e não na transação, se perde entre uma transação e a próxima. SET, LISTEN/NOTIFY, advisory locks de sessão e tabelas temporárias deixam de se comportar como você espera. Antes de trocar a URL, confira que a aplicação não depende de nenhum deles. No vertex-api, essa conferência ficou escrita no próprio código: nada disso é usado, e os três lugares que abrem transação fazem todo o trabalho dentro dela.

Prepared statements: o conselho antigo e por que eu desligo mesmo assim

Os prepared statements do protocolo do Postgres também vivem na conexão física, não na sua sessão lógica. Se o driver prepara uma query numa transação e a próxima transação cai em outra conexão física, o Postgres reclama que o statement não existe. Se o nome colide com o de outro cliente, reclama que ele já existe. Os dois erros têm a mesma causa.

Por anos, a recomendação foi uma só: atrás de um pooler em transaction mode, desligue os prepared statements. Esse conselho envelheceu. O PgBouncer 1.21, de 2023, passou a rastrear os prepared statements do protocolo (a opção max_prepared_statements) e a recriá-los na conexão em que a próxima transação cair; a partir do 1.24, isso vem ligado por padrão. O pooler do Neon rastreia esses statements. O que continua quebrando é o PREPARE escrito em SQL, que o PgBouncer não enxerga.

Mesmo assim, o vertex-api roda com prepare: false, porque aqui o erro é assimétrico. Se o cache de statements do driver e o do pooler discordarem, o sintoma só aparece com pooling, só em produção e só de vez em quando, como um prepared statement ... does not exist ou already exists. No postgres-js, desligar custa mais que um parse: sem statement nomeado, cada query com parâmetros paga uma ida e volta extra (Parse/Describe e só depois Bind/Execute). E, com Drizzle, a opção não muda nada: o driver do Drizzle executa tudo por sql.unsafe(), que no postgres-js já roda sem prepared statement por padrão. Por isso a opção fica como trava, não como otimização: protege qualquer query que um dia use o postgres-js direto, fora do Drizzle. Religar só faz sentido com uma medição dizendo que importa.

// vertex-api: opções de toda conexão postgres-js da aplicação
import type { Options } from 'postgres';

export const postgresClientOptions: Options<Record<string, never>> = {
  // Erro assimétrico: uma ida e volta a mais por query com parâmetros contra um bug intermitente só em produção.
  prepare: false,
  // Atrás do pooler, este pool só cobre as queries em voo de um processo.
  // O que chega ao Neon é instâncias × max.
  max: 5,
  // Segundos: devolve conexões ociosas em vez de segurá-las pela vida do processo.
  idle_timeout: 20,
};

O arquivo completo, com o raciocínio de cada opção num comentário, está no vertex-api.

Em outras ferramentas, a mesma decisão aparece com outro nome:

  • Prisma: por muito tempo, a solução foi o parâmetro ?pgbouncer=true na URL, que desliga os prepared statements do protocolo. Hoje a documentação do Prisma recomenda não usá-lo com PgBouncer 1.21 ou mais novo e, em vez disso, ligar o max_prepared_statements no pooler.
  • node-postgres (pg): só usa prepared statements com nome quando você pede (query({ name, text, values })). Por isso tende a ser seguro por acidente, e também não ganha o cache de plano que um statement com nome daria numa conexão direta.

O ponto comum: a configuração certa do driver depende de saber de antemão se há um pooler em transaction mode no caminho, e qual a versão dele. Copiar a configuração de um projeto com Postgres próprio, sem pooler, para um projeto no Neon ou no Supabase é receita para esse bug.

Duas URLs, dois propósitos

A prática que virou consenso, popularizada pelo Prisma mas útil com qualquer ORM, é separar a URL de conexão em duas:

# .env
# Usada pela aplicação em runtime: passa pelo pooler,
# muitas conexões lógicas, poucas físicas.
DATABASE_URL="postgresql://user:pass@ep-xxx-pooler.sa-east-1.aws.neon.tech/db?sslmode=require"

# Usada só por migrations e scripts administrativos:
# conexão direta, sem pooler.
DIRECT_URL="postgresql://user:pass@ep-xxx.sa-east-1.aws.neon.tech/db?sslmode=require"

A aplicação usa DATABASE_URL, com pooler. O pipeline de migração (drizzle-kit migrate, prisma migrate deploy) usa DIRECT_URL. Ferramentas de migração costumam depender justamente do que o transaction mode não preserva: o Prisma Migrate, por exemplo, segura um advisory lock de sessão durante a migração inteira, e em transaction mode essa sessão não existe.

Como detectar antes que vire incidente

Não espere o erro em produção para descobrir o limite. O próprio Postgres mostra:

-- Quantas conexões estão abertas agora, por estado
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;

-- O teto configurado
SHOW max_connections;

Atrás de um pooler, pg_stat_activity mostra as conexões do pooler, não as da aplicação. O lado da aplicação aparece nas métricas do pooler e no gráfico de conexões do Neon ou do Supabase. Esse gráfico é o primeiro lugar a olhar quando a API começa a devolver erros intermitentes sob carga que "não fazem sentido". Se a curva de conexões sobe em serrote até bater no teto exatamente quando os erros aparecem, você achou a causa antes de abrir uma única stack trace.

O que fica

Serverless não eliminou o problema clássico de connection pooling; só mudou a matemática. A pergunta deixou de ser "quantas conexões meu processo precisa" e virou "quantas conexões o conjunto de todas as instâncias que podem existir ao mesmo tempo precisa". Essa conta quase nunca aparece nos tutoriais de "conecte seu Next.js ou NestJS ao Postgres em 5 minutos".

Trate a conexão de banco como um recurso escasso, dividido entre todas as invocações simultâneas da aplicação, e não como um detalhe de configuração por instância. E desconfie de conselho de configuração sem data: o dos prepared statements valia até o fim de 2023 e continua circulando como regra.

Comentários

Carregando comentários...