Connection pooling en Postgres para cargas serverless
Las funciones serverless no mantienen conexiones a base de datos — las abren, ejecutan una query y las descartan. Connection pooling en Postgres para serverless multiplexa miles de clientes efímeros sobre un pool upstream pequeño, o la base llega a "too many connections" con tráfico normal.
Una función en Vercel que abre una conexión directa a Postgres en cada cold start no tiene problema de conexión en el primer request. Lo tiene en la quincuagésima invocación concurrente, cuando Postgres devuelve FATAL: sorry, too many clients already y la mitad de los requests cuelgan hasta timeout. Los runtimes serverless escalan instancias en horizontal. Postgres escala conexiones en vertical — y cada conexión forkea un proceso backend que consume unos 5–10 MB de RAM. Connection pooling en Postgres para serverless no es una capa de optimización. Es el mecanismo que permite que funciones sin estado hablen con una base con estado sin agotar el techo de conexiones en un pico de tráfico que sería normal para la capa de aplicación.
El fallo es fácil de no ver en desarrollo. Tests locales corren una instancia de función. Staging corre diez. Producción corre quinientos cold starts durante un deploy, cada uno reclamando un slot que Postgres mantiene hasta que el cliente cierra o vence el idle timeout — a menudo más que la vida de la función si el driver hace pool mal o el connection string apunta al host directo.
Las conexiones directas fallan en silencio hasta fallar en catástrofe
Postgres asigna un proceso servidor por conexión de cliente. En instancias managed con límite de 60–200 conexiones según el tier de compute, la cuenta es implacable. Doscientas invocaciones serverless concurrentes abriendo conexión directa consumen todo el presupuesto. Workers en background, herramientas de migración, agentes de monitoreo y el pooler también necesitan slots. La aplicación no recibe 200 — recibe lo que queda después de que el resto del fleet reclama su parte.
La secuencia de síntomas es predecible. La latencia p99 de queries sube primero — conexiones hacen cola en la capa accept de Postgres. Luego suben las tasas de error — conexiones nuevas rechazadas. Finalmente fallan servicios no relacionados porque la base compartida agotó slots. Dashboards muestran errores de aplicación. Los logs de base muestran breaches del límite de conexiones. La corrección no es solo "escalar compute" — instancias más grandes suben el techo pero no eliminan el desajuste entre clientes efímeros y procesos servidor de larga vida.
Las funciones serverless agotan conexiones; no las sostienen con responsabilidad.
Backends de larga vida — una API Node.js en contenedor, un servidor Rails, un worker en background — pueden mantener un pool in-process pequeño de cinco a veinte conexiones amortizado en miles de requests. Serverless rompe esa suposición. Cada invocación es un cliente potencialmente nuevo. Autoscaling multiplica clientes más rápido de lo que Postgres puede forkear procesos. El pooling debe ocurrir fuera de la función — en un proxy que multiplexa muchas sesiones cliente cortas sobre pocas conexiones upstream.
El modo transacción es el default para pooling serverless
Los poolers de conexión operan en dos modos que importan para decisiones de arquitectura.
Modo transacción asigna una conexión upstream de Postgres a un cliente solo durante una transacción. Al commit o rollback, la conexión vuelve al pool. El siguiente cliente — posiblemente otra invocación de función — la recibe. Esto calza con semántica serverless: abrir, query, commit, cerrar. Miles de instancias de función pueden compartir diez a cincuenta conexiones upstream si cada query corre dentro de un límite de transacción explícito.
Modo sesión mantiene la conexión upstream durante toda la sesión del cliente. Requerido para prepared statements que persisten entre queries, advisory locks, comandos SET, tablas temporales y LISTEN/NOTIFY. Funciones serverless que necesitan estado con scope de sesión no deberían correr en pools de modo transacción sin rediseño.
| Modo | Tiempo de hold upstream | Encaje serverless | Features de sesión |
|---|---|---|---|
| Transacción | Una transacción | Default para edge y funciones | Sin prepared statements entre llamadas |
| Sesión | Toda la conexión cliente | Solo backends persistentes | Prepared statements, locks, temp tables |
En Supabase, el pooler compartido Supavisor expone modo transacción en puerto 6543 y modo sesión en puerto 5432 vía el hostname del pooler. Conexiones directas usan db.[project].supabase.co:5432 para migraciones, pg_dump y tareas admin — no para código de aplicación en runtimes serverless.
Planes pagos también ofrecen PgBouncer dedicado co-ubicado con la instancia de base — solo modo transacción, menor latencia que el pooler multi-tenant compartido, alcanzable en puerto 6543 vía el host directo de base. El trade-off es operativo: Supavisor compartido agrega ~1–2 ms por query pero aísla carga del pool del CPU de la base. PgBouncer dedicado es más rápido por query pero comparte compute con Postgres.
Un connection string serverless para Postgres tiene cuatro requisitos
Connection strings mal configurados causan más incidentes de producción que índices faltantes. Cuatro ajustes definen comportamiento correcto de connection pooling en Postgres para serverless.
Apuntar al pooler, no al host directo. Código de aplicación en Vercel, Cloudflare Workers, AWS Lambda y similares debe usar la URL del pooler en modo transacción. Migraciones y scripts admin one-off usan conexión directa.
Definir connection_limit=1 por instancia de función. Runtimes serverless no se benefician de pools in-process de diez conexiones — cada instancia maneja un request a la vez en configuraciones típicas. Múltiples conexiones por instancia multiplican consumo de slots sin ganancia de throughput.
Deshabilitar prepared statements en modo transacción. PgBouncer y Supavisor en modo transacción no garantizan la misma conexión upstream entre queries. ORMs que preparan por default — Prisma es el caso común — necesitan ?pgbouncer=true o flags equivalentes del driver para evitar errores prepared statement already exists.
Mantener transacciones cortas. Transacciones largas retienen conexiones upstream y bloquean el pool. Una función serverless que abre transacción, llama una API externa y luego hace commit retiene un slot durante la espera de red. Patrón: leer, computar, escribir — I/O externo fuera de límites de transacción.
# Supabase modo transacción (tráfico de aplicación serverless)
DATABASE_URL="postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true"
# Conexión directa (solo migraciones — nunca desde funciones serverless)
DIRECT_URL="postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres"import { Pool } from "pg"
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 1,
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 5_000,
})
export async function getUser(id: string) {
const client = await pool.connect()
try {
await client.query("BEGIN")
const result = await client.query(
"SELECT id, email FROM profiles WHERE id = $1",
[id]
)
await client.query("COMMIT")
return result.rows[0]
} catch (err) {
await client.query("ROLLBACK")
throw err
} finally {
client.release()
}
}Las políticas Row-Level Security siguen evaluándose por query en conexiones pooled — el JWT o rol de sesión debe setearse dentro de la misma transacción. En Supabase, el cliente JavaScript vía PostgREST evita conexiones Postgres directas; el pooling importa para código server-side que usa el driver de base directamente. Equipos que optimizan rendimiento de RLS a escala componen latencia del pooler con costo de política por fila — otra razón para transacciones cortas e indexadas.
Cuándo el pooling duele más de lo que ayuda
El pooling no es gratis. Un proxy agrega latencia de hop — típicamente 1–2 ms por query en Supavisor, menos en PgBouncer co-ubicado. Para una query que corre en 0,5 ms, ese overhead importa. Para una que corre en 50 ms, no.
Omitir el pooler cuando:
- Un solo proceso de larga vida sirve todo el tráfico con pool in-process dimensionado a concurrencia real. Un servidor Node.js dedicado en VM con
max: 20no necesita pooler externo salvo acercarse a límites de conexión entre múltiples instancias. - El cliente usa PostgREST o la API HTTP de Supabase. Esos caminos no abren conexiones Postgres crudas desde código de aplicación. El pooling es problema de la plataforma.
- Se requieren features con scope de sesión — caches de prepared statements entre requests, advisory locks para coordinación de jobs,
COPYen contexto de sesión. Correr estos en endpoints de pooler en modo sesión o conexiones directas desde workers persistentes, no desde caminos serverless en modo transacción.
Usar el pooler cuando:
- Runtimes serverless o autoscaling abren conexiones por invocación.
- Se requiere IPv4 y el host directo es solo IPv6 — el pooler compartido de Supabase provee IPv4 en todos los tiers.
- El conteo de conexiones se acerca a límites de instancia en load tests que reflejan invocaciones concurrentes, no curls secuenciales.
¿Cómo funciona connection pooling en Postgres para serverless?
Estas preguntas cubren decisiones que equipos erran al pasar de desarrollo local a deploys serverless en producción.
¿Las funciones serverless deben usar modo transacción o modo sesión?
Modo transacción es el default para funciones serverless. Cada invocación debe abrir conexión, ejecutar una o más sentencias dentro de una transacción, hacer commit y liberar. Modo sesión es para backends persistentes que necesitan prepared statements, advisory locks o persistencia de SET entre queries. Apuntar código de aplicación serverless a puerto 6543 (modo transacción en Supabase). Apuntar herramientas de migración y workers de larga vida a conexiones directas o endpoints en modo sesión.
¿Por qué Prisma falla con "prepared statement already exists" detrás de un pooler?
Poolers en modo transacción reasignan conexiones upstream entre clientes. Prisma prepara statements por default y espera que la misma conexión persista. La siguiente query puede caer en otra conexión upstream donde el nombre del prepared statement colisiona. Agregar ?pgbouncer=true a la URL para que Prisma use prepared statements sin nombre compatibles con transaction pooling.
¿El cliente JavaScript de Supabase necesita connection pooling?
No — para uso típico frontend y serverless vía supabase-js, las queries van por PostgREST sobre HTTP, no conexiones Postgres directas. El pooling importa para código server-side con pg, Drizzle con driver directo, Prisma o runtimes edge que conectan a Postgres nativamente. Si el único acceso a base es vía el SDK de Supabase, la configuración de pooling es irrelevante para código de aplicación.
Una visión opuesta
Un argumento frecuente sostiene que funciones serverless no deberían conectar a Postgres directamente — que todo acceso a base debería fluir por una capa API, gateway de edge functions o PostgREST, haciendo del pooling a nivel driver un anti-patrón heredado de arquitecturas serverful.
Ese argumento es correcto para muchas arquitecturas de producto. Es incompleto para server-side rendering, jobs en background co-ubicados en runtimes serverless, migraciones de código legacy con ORM pesado y edge databases que exponen wire protocol de Postgres. Cuando existen conexiones directas, el pooling es obligatorio — la alternativa no es "usar HTTP en su lugar" sino "agotar conexiones y fallar bajo carga".
La división productiva: HTTP/API para acceso a datos client-facing donde sea posible; conexiones directas pooled para código server-side que genuinamente necesita acceso wire-protocol, con modo transacción y max: 1 por instancia.
Lo que importa recordar
- Connection pooling en Postgres para serverless es obligatorio cuando funciones abren conexiones wire-protocol — conexiones directas agotan slots antes de que las queries fallen visiblemente.
- Modo transacción multiplexa muchos clientes efímeros sobre pocas conexiones upstream; modo sesión es para backends persistentes con features de sesión.
- Supabase: puerto 6543 para tráfico de aplicación serverless, host directo para migraciones,
pgbouncer=truepara Prisma. - Definir
max: 1(oconnection_limit=1) por instancia serverless — pools in-process multiplican consumo de slots sin beneficio. - Mantener transacciones cortas; I/O externo fuera de límites de transacción libera conexiones upstream más rápido.
- El pooling agrega ~1–2 ms por query — omitirlo para servidores únicos de larga vida con acceso solo PostgREST.
Conclusión
El emparejamiento serverless más Postgres falla en la capa de conexión, no en la de query. Aplicaciones que funcionan localmente chocan con límites de producción porque autoscaling multiplica clientes que Postgres nunca fue diseñado para aceptar uno a uno. El pooling traduce demanda efímera en una huella upstream acotada — modo transacción, una conexión por instancia, transacciones cortas, connection string correcto.
La auditoría de configuración es corta: rastrear cada runtime que abre conexión con driver Postgres, verificar que apunta a pooler en modo transacción, confirmar prepared statements deshabilitados donde haga falta, y load-test con invocaciones concurrentes en lugar de requests secuenciales. Todo lo demás — índices, políticas RLS, planes de query — es irrelevante si la conexión nunca se establece. Arreglar el pool primero.


