Pular para o conteúdo
upgbp

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.

9 min de leitura

O erro tratado aqui

Timed out fetching a new connection from the connection pool. (Current connection pool timeout: 10, connection limit: 9)

Ambiente testado

  • Next.js 15.3
  • Prisma 6.5
  • Supabase Postgres 15
  • Vercel · Node 22
Neste artigo (9)

Contexto: o que estava rodando

Uma aplicação Next.js no App Router, hospedada na Vercel, com Prisma falando com um Postgres do Supabase. Nada exótico: rotas de API lendo e escrevendo pedidos, uma página de listagem com paginação, e um webhook de pagamento que grava o status da transação.

Em desenvolvimento, tudo passava. Em produção com trânsito baixo, também. O problema apareceu no primeiro pico real — uma campanha que trouxe algumas centenas de acessos simultâneos. A partir de um certo volume, uma fatia das requisições começou a devolver 500, enquanto outra fatia continuava respondendo normalmente. Esse detalhe — parte falhando — é a primeira pista, e vou voltar nela.

O erro

vercel logs --prodexit 1
PrismaClientKnownRequestError:
Invalid `prisma.order.findMany()` invocation:

Timed out fetching a new connection from the connection pool.
More info: http://pris.ly/d/connection-pool
(Current connection pool timeout: 10, connection limit: 9)
    at Ai.handleRequestError (/var/task/node_modules/@prisma/client/runtime/library.js:121:7749)
    at Ai.handleAndLogRequestError (/var/task/node_modules/@prisma/client/runtime/library.js:121:7115)
    at Ai.request (/var/task/node_modules/@prisma/client/runtime/library.js:121:6799)
  code: 'P2024'

E, intercalado com ele, um segundo erro — diferente, vindo do banco e não do Prisma:

supabase · logs → postgresexit 1
FATAL:  remaining connection slots are reserved for non-replication superuser connections
FATAL:  sorry, too many clients already

São dois erros distintos, com causas encadeadas. Tratá-los como se fossem o mesmo é o que faz a maioria das tentativas de correção falhar.

Diagnóstico

O connection limit: 9 não é o limite do banco

A primeira leitura errada que fiz foi assumir que connection limit: 9 era uma configuração minha. Não é. Quando você não define nada, o Prisma calcula o tamanho do pool assim:

connection_limit = num_physical_cpus × 2 + 1

O runtime da Vercel reportava 4 vCPUs, daí o 9. Esse número descreve o pool interno do Prisma dentro daquela instância, não a capacidade do Postgres.

Cada invocação concorrente tem o próprio pool

Aqui está o ponto que muda tudo. Em serverless não existe “o servidor”. Existem N instâncias da função, criadas sob demanda, e cada instância que executa new PrismaClient() carrega o próprio pool de 9 conexões.

Com 50 invocações concorrentes, a conta é:

50 instâncias × 9 conexões = 450 conexões abertas contra o Postgres

Um projeto Supabase no plano inicial trabalha com algo na casa de 60 conexões diretas disponíveis, já descontando as reservadas para superusuário. O banco recusa o excedente — daí o too many clients already. E as instâncias que não conseguiram abrir conexão ficam esperando no próprio pool até estourar o timeout de 10 segundos — daí o P2024.

Cinquenta instâncias serverless, cada uma com um pool de nove conexões, somam 450 conexões contra um Postgres que aceita cerca de 60. As 390 excedentes são barradas na entrada.
50 × 9 = 450 conexões contra ~60 slots. O excedente é recusado na entrada; quem fica esperando vaga estoura o timeout de 10 s.

O terceiro suspeito: a query que segura a conexão

Vale medir antes de mexer na infraestrutura. Uma conexão só volta ao pool quando a query termina. Uma query de 800 ms segura a conexão por 800 ms; a mesma query com o índice certo devolve em 12 ms e libera o slot 60 vezes mais rápido. Pool pequeno com query rápida costuma sustentar muito mais carga do que pool grande com query lenta.

Foi o que rodei no SQL Editor do Supabase para confirmar quem estava segurando o quê:

-- Quantas conexões existem agora, e em que estado
select state, count(*)
from pg_stat_activity
where datname = current_database()
group by state
order by count desc;

-- As consultas que mais consomem tempo total no banco
select
  calls,
  round(mean_exec_time::numeric, 2) as media_ms,
  round(total_exec_time::numeric, 2) as total_ms,
  query
from pg_stat_statements
order by total_exec_time desc
limit 10;

O resultado foi revelador: a maior parte das conexões estava em idle. Não era o banco trabalhando demais — era conexão parada ocupando vaga.

A solução

  1. Apontar a aplicação para o pooler, não para o banco direto.

    O Supabase expõe duas portas. A 5432 é conexão direta com o Postgres. A 6543 é o Supavisor em modo transaction (a diferença entre as três formas de conectar merece atenção própria): ele mantém um punhado de conexões reais com o banco e as empresta por transação para centenas de clientes.

    Serverless precisa da 6543. É exatamente o cenário para o qual o pooler existe: muitos clientes efêmeros, cada um usando a conexão por milissegundos.

    .env.production
    # Aplicação — via pooler, modo transaction
    DATABASE_URL="postgresql://postgres.SEU_REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"
    
    # Migrations — conexão direta, sem pooler
    DIRECT_URL="postgresql://postgres.SEU_REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres"
  2. Baixar connection_limit para 1.

    Parece contraintuitivo, mas é a configuração correta atrás de um pooler em modo transaction. Cada invocação atende uma requisição por vez; um pool de 9 por instância só serve para multiplicar conexões ociosas. Com 1, aquelas 450 conexões do exemplo viram 50 — e o Supavisor as multiplexa em um número bem menor de conexões reais.

    O pgbouncer=true na mesma URL é obrigatório: ele desliga os prepared statements do Prisma. Sem isso você troca este erro por outro, prepared statement "s0" already exists, porque o pooler devolve conexões diferentes a cada transação.

  3. Separar a URL de migrations no schema.

    Migrations precisam de conexão direta — elas usam recursos de sessão que o modo transaction não suporta.

    prisma/schema.prisma
    datasource db {
      provider  = "postgresql"
      url       = env("DATABASE_URL")
      directUrl = env("DIRECT_URL")
    }
  4. Garantir uma única instância do PrismaClient.

    Em produção o problema é o número de instâncias da função. Em desenvolvimento é o hot reload, que recria o módulo e vaza um PrismaClient novo a cada alteração de arquivo, até o banco local recusar conexões.

    src/lib/prisma.ts
    import { PrismaClient } from '@prisma/client';
    
    const globalForPrisma = globalThis as unknown as {
      prisma: PrismaClient | undefined;
    };
    
    export const prisma =
      globalForPrisma.prisma ??
      new PrismaClient({
        log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
      });
    
    // Em produção o módulo é congelado no bundle; guardar no global só importa
    // no dev, onde o hot reload reavalia este arquivo a cada salvamento.
    if (process.env.NODE_ENV !== 'production') {
      globalForPrisma.prisma = prisma;
    }

    Todo acesso ao banco passa a importar deste arquivo. Um new PrismaClient() perdido em qualquer rota anula as três etapas anteriores.

Como confirmar que resolveu

Não confie no “parou de dar erro”. Três verificações objetivas:

1. A contagem de conexões diretas caiu. Rodando o pg_stat_activity durante carga equivalente, o total tem que ficar estável e baixo, em vez de subir junto com o tráfego.

2. As migrations continuam rodando. Este é o teste que gente esquece — a DIRECT_URL errada só aparece no próximo deploy com migration:

npx prisma migrate deploy
Applying migration `20260822_add_order_status_index`
The following migration have been applied:

migrations/
  └─ 20260822_add_order_status_index/
    └─ migration.sql

All migrations have been successfully applied.

3. O erro P2024 sumiu dos logs sob carga. Vale gerar carga de propósito, em vez de esperar o próximo pico orgânico:

npx autocannon -c 100 -d 30 https://seu-app.vercel.app/api/orders

Armadilhas que sobram depois disso

Prepared statements. Se aparecer prepared statement "s0" already exists, o motivo está em como o pooler empresta conexões — falta o pgbouncer=true na DATABASE_URL — ou algum ponto do código está abrindo conexão com uma URL diferente.

Transações longas. No modo transaction, a conexão fica presa ao cliente durante toda a transação. Um prisma.$transaction() que faz chamada HTTP no meio segura um slot do pooler pelo tempo da chamada externa. Chamada de rede não entra dentro de transação.

Advisory locks e LISTEN/NOTIFY. Dependem de estado de sessão e não funcionam pela porta 6543. Se você usa esses recursos, aquele caminho específico precisa da conexão direta.

O índice ainda é o que mais rende. Ajustar o pool contorna o sintoma. Uma query de listagem sem índice em status continuará sendo o gargalo no próximo pico — só que agora com o pool bem configurado. Diagnóstico de query lenta com EXPLAIN ANALYZE é assunto de outro artigo, e é para lá que eu iria em seguida.

Continue por aqui