Connection pooling en Supabase: por qué te quedas sin conexiones

Panel de monitorización de base de datos Postgres con gráficas de sesiones activas

Un viernes de octubre de 2024 recibimos un mensaje a las 22:40: «la app cae cada vez que entran más de treinta usuarios, pero la base de datos está al 4% de CPU». Ese contraste es la firma del problema. Cuando el reparto de conexiones está mal planteado, te quedas sin disponibilidad mucho antes de quedarte sin recursos de cómputo, y el panel de métricas no te dice nada útil porque mide justo lo que no se está rompiendo.

Nuestro equipo ha diagnosticado este fallo en 23 proyectos durante los últimos dos años, y casi siempre el origen es una mala configuración de connection pooling en Supabase. En 19 de ellos la causa estaba en el código o en la cadena de conexión, no en el plan contratado. Subir de instancia compró tiempo en cuatro casos. En el resto, habría sido pagar más por el mismo error.

Antes de entrar: 23 proyectos no son una muestra estadística, son nuestra muestra, y está sesgada hacia equipos que nos llaman cuando algo ya se ha roto. Tómalo como patrones observados, no como ley universal. Lo que sigue es el protocolo que usamos: síntomas, anatomía del límite, cuatro causas raíz, diagnóstico en diez minutos y tratamiento. En ese orden, porque invertirlo es lo que lleva a gente competente a triplicar la factura sin arreglar nada.

Los síntomas que delatan un aforo agotado

El cuadro clínico es muy reconocible una vez lo has visto tres veces. Fallos que no se reproducen en local, logs que apuntan a la red y un patrón temporal que coincide con picos de tráfico o con despliegues.

«too many clients already» y los errores intermitentes

El mensaje canónico es FATAL: remaining connection slots are reserved for non-replication superuser connections o, en su variante corta, sorry, too many clients already. Postgres no está pidiendo ayuda: está rechazando.

¿Y cuándo no aparece ese texto? Cuando hay un intermediario delante. Entonces el síntoma se disfraza: timeouts de 15 segundos, Connection terminated unexpectedly, o el delicioso Max client connections reached de Supavisor. Tres mensajes distintos, una sola enfermedad.

Un apunte incómodo antes de seguir: no todo fallo intermitente es tuyo. Se han reportado tanto incidentes de plataforma como episodios de latencia elevada en las APIs gestionadas, así que el primer paso honesto es consultar la página de estado del proveedor y descartar lo externo. (Nos saltamos ese paso una vez y pasamos cuatro horas auditando un código que estaba perfectamente bien.) Hay además un falso positivo clásico: los proyectos del plan gratuito se pausan por inactividad, algunos usuarios lo reportan incluso con 500 a 700 peticiones diarias, y el error resultante se parece bastante a un agotamiento sin serlo.

En un SaaS de facturación que auditamos en marzo pasado, el equipo llevaba once días persiguiendo un supuesto problema de red con su proveedor de hosting. El 94% de los fallos ocurrían entre las 9:00 y las 9:20, justo cuando 40 gestorías abrían la herramienta a la vez. La red no tenía nada que ver.

Cuando el fallo aparece solo bajo carga o en despliegues serverless

Si tu aplicación corre en Vercel, Netlify, Lambda o Cloudflare Workers, cada petición puede levantar su propio entorno de ejecución. Diez peticiones simultáneas no son diez sesiones: pueden ser diez entornos reclamando cinco plazas cada uno.

Cincuenta. Contra una instancia pequeña cuyo aforo directo ronda las 60. Ahí lo tienes.

Brutal de claro es el patrón diagnóstico: funciona perfecto con un usuario, funciona bien con cinco, se cae con treinta y vuelve a funcionar solo cuando la cosa se calma. Nada de eso es aleatorio aunque lo parezca desde el frontend.

Qué cuenta realmente: clientes, intermediario y base de datos

Una conexión no es un número, son tres. Tu aplicación abre sesiones de cliente contra el intermediario; el intermediario mantiene un número mucho menor de conexiones reales contra el motor; y el motor tiene un aforo máximo fijado por max_connections. Confundir cualquiera de las tres capas con otra es el origen de casi todos los diagnósticos erróneos.

Ese malentendido genera el 70% de las consultas que recibimos. Hay tres números en juego y la gente los trata como si fueran uno.

Sesiones de cliente frente a conexiones directas a Postgres

Postgres asigna un proceso del sistema operativo a cada sesión. No un hilo: un proceso, con su memoria reservada. Por eso el límite existe y por eso no se puede subir alegremente a 2.000.

El intermediario se sitúa en medio y rompe la relación uno a uno. Tu aplicación habla con Supavisor, Supavisor mantiene un puñado de canales reales contra el motor y los reparte. Mil clientes por delante, quince conexiones por detrás: eso es exactamente lo que compras al enchufar esta capa.

La cadena de conexión te dice en qué lado estás. El host db.<ref>.supabase.co en el puerto 5432 va directo al motor. El host del intermediario en el puerto 6543 pasa por Supavisor en modo transacción. Cambiar una letra de ahí cambia todo el comportamiento del sistema bajo carga. Si quieres la terminología oficial sin intermediarios interpretativos, conviene leerla en la documentación de conexión de Supabase, donde los maintainers distinguen explícitamente entre clients, client connections y conexiones directas.

Pool size y max_connections no son lo mismo que usuarios concurrentes

Imagina un restaurante con 15 mesas y 300 reservas al día. No necesitas 300 mesas: necesitas que nadie se quede sentado tres horas después del postre. El pool size son las mesas; max_connections es el aforo legal del edificio; los usuarios concurrentes son los comensales que quieren entrar.

Valores orientativos de la plataforma, para que tengas la escala en la cabeza: alrededor de 60 conexiones directas en Micro, unas 90 en Small, cerca de 120 en Medium y en torno a 240 en XL, con un dimensionado por defecto que ronda los 15 canales en los tamaños pequeños. Verifícalos en tu panel antes de calcular nada: son cifras que el proveedor ajusta y que cambian con el tiempo.

Nota mental que repetimos en cada auditoría: el intermediario también consume plazas del aforo. Si reservas 50 canales contra un motor de 60, te quedan 10 para migraciones, para el editor SQL del panel y para cualquier script de mantenimiento. Y las migraciones fallan en el peor momento posible. (Siempre. Nunca un martes tranquilo a las once de la mañana.)

Switch de red con cables representando la multiplexación de sesiones hacia Postgres

Las cuatro causas raíz del agotamiento

De los 23 casos que hemos tratado, todos encajaban en una de estas cuatro categorías. Algunos en dos a la vez, que es el escenario divertido.

Usar el puerto directo donde deberías usar Supavisor

Causa número uno por volumen: 11 de 23 proyectos. El origen casi siempre es el mismo, y no es negligencia.

Alguien copió la cadena del panel el primer día, cuando el proyecto era un prototipo con dos usuarios, y esa cadena nunca se tocó más. Funcionó durante ocho meses. Dejó de funcionar el día que el producto empezó a importar.

El acceso directo al motor tiene su sitio: migraciones, herramientas de administración, procesos de larga duración con estado. Para el tráfico de una API web no, salvo que controles con exactitud cuántos procesos la atacan.

Session mode cuando tu aplicación necesita transaction mode

En modo sesión, Supavisor reserva un canal real para tu cliente desde que se conecta hasta que se desconecta. Si tu cliente vive tres horas, ese canal vive tres horas. El reparto efectivo que obtienes es, redondeando, ninguno.

En modo transacción el canal se libera al terminar cada transacción. Un cliente que hace una consulta de 40 milisegundos ocupa 40 milisegundos, no tres horas. La diferencia de capacidad entre ambos modos, en nuestras mediciones, ha ido de 8x a 40x con la misma configuración de base.

Criterio Transaction mode (6543) Session mode
Ocupación del canal real Solo durante la transacción Toda la vida de la sesión
Carga ideal API web, serverless, edge, muchas peticiones cortas Procesos largos con estado, migraciones, herramientas de administración
Clientes servidos por canal Muchos, de forma secuencial Uno
Sentencias preparadas persistentes No disponibles Disponibles
LISTEN/NOTIFY y advisory locks de sesión No disponibles Disponibles
Variables de sesión No persisten entre transacciones Persisten

Ese precio hay que pagarlo con conocimiento. Si tu ORM depende de lo que la columna derecha ofrece, hay que desactivarlo explícitamente. Prisma necesita pgbouncer=true en la URL; con el driver pg de Node hay que vigilar las sentencias preparadas que la librería genera por su cuenta.

Clientes instanciados por petición y fugas que nunca cierran

Este es el que más vergüenza da cuando se encuentra, y lo decimos con cariño porque lo hemos cometido nosotros. En 2023, en un proyecto propio, teníamos un createClient() dentro del handler de una ruta. Cada petición, un cliente nuevo. Ninguno se cerraba.

¿El resultado? Setecientas sesiones zombi acumuladas en seis horas, un deploy de emergencia a las dos de la mañana y una lección que no se olvida. Dos líneas de código. Seis horas de incidencia.

Más sutil es la otra variante de fuga: una excepción que salta entre el connect() y el release() sin bloque finally. Funciona el 99% de las veces y acumula un proceso huérfano cada vez que algo va mal.

Y una advertencia que ahorra días: no siempre la fuga es tuya. Hay fugas reportadas en Supavisor con clientes JDBC, concretamente con SingleConnectionDataSource y PGSimpleDataSource, documentadas en el issue 595 del repositorio, en las que el problema persiste aunque la aplicación cierre correctamente. Si trabajas sobre JVM y tu código está limpio, revisa ese hilo antes de seguir buscando en tu lado.

Cuando tu aplicación arrastra además consultas lentas que amplifican el efecto, conviene cruzarlo con el análisis de rendimiento: nuestra guía sobre detectar consultas N+1 explica cómo una sola ruta mal construida multiplica por cincuenta el tiempo de ocupación de cada canal, lo que convierte un dimensionado sobrado en uno agotado sin tocar nada más del sistema.

Pools locales del ORM multiplicados por cada instancia desplegada

Causa silenciosa y la más difícil de ver desde dentro del código. Tu ORM trae su propio reparto local con un valor por defecto. SQLAlchemy arranca con 5 más 10 de overflow. El driver pg de Node, con 10. Prisma calcula su tamaño según los núcleos disponibles.

Ahora multiplica por el número de instancias que tu plataforma tiene vivas. Seis contenedores con diez plazas cada uno son 60 conexiones reclamadas, y nadie ha escrito en ninguna parte el número 60. No aparece en tu repositorio, no aparece en el panel, no aparece en la documentación de tu ORM. Es un número emergente.

La fórmula que usamos en revisiones:

(pool_size + overflow) × instancias_máximas ≤ 80% del aforo disponible

Ese 20% de margen no es paranoia, es espacio para migraciones y para el día en que el autoescalado decida levantar dos contenedores extra sin consultarte.

Diagnóstico en diez minutos: cómo saber cuál es tu caso

Nada de teoría a partir de aquí. Abre el editor SQL del proyecto y sigue estos cuatro pasos en orden.

Consultas a pg_stat_activity que revelan quién ocupa las plazas

  1. Cuenta por estado. Ejecuta el recuento agrupado y fíjate en la antigüedad máxima de cada grupo.
  2. Identifica a los ocupantes. Agrupa por aplicación, usuario y dirección de cliente para saber quién está realmente dentro.
  3. Corta el tráfico 60 segundos y vuelve a contar.
  4. Mira la forma de la curva de los últimos siete días en el panel de métricas.

Paso uno, el recuento por estado:

SELECT state, count(*), max(now() - state_change) AS mas_antigua
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state
ORDER BY count(*) DESC;

Lo que buscas son los idle in transaction. Son transacciones abiertas que nadie ha cerrado: ocupan su plaza y además bloquean el autovacuum. Si ves cinco o más con antigüedad superior a un minuto, el diagnóstico está hecho y es una fuga.

Paso dos, quién está detrás:

SELECT application_name, usename, client_addr, state, count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY 1,2,3,4
ORDER BY 5 DESC;

Ese application_name vale oro. En uno de los diagnósticos de enero descubrimos 34 sesiones etiquetadas como la herramienta de BI que el equipo de datos había conectado tres semanas antes sin avisar a nadie. El backend tenía la culpa de cero errores. (El equipo de datos tampoco: nadie les había dicho que aquello compartía aforo.)

Cómo distinguir una fuga de un simple exceso de concurrencia

Definitiva y de cinco minutos es la prueba del paso tres. Para el tráfico de producción durante 60 segundos, o despliega una versión en mantenimiento, y vuelve a contar.

Si el número cae a dos o tres, no tienes fuga: tienes más demanda que capacidad y toca configurar. Si se queda en 180 con cero tráfico entrando, tienes una fuga y ningún plan superior te va a salvar de ella.

Segundo indicador, el del paso cuatro: la forma de la curva. Una fuga dibuja una escalera que solo sube y se resetea con cada reinicio del proceso; en cambio un exceso de concurrencia dibuja dientes de sierra que suben y bajan con el tráfico.

Con esos cuatro datos ya puedes recorrer el árbol de decisión:

  1. ¿La página de estado del proveedor reporta incidencia? Sí → espera y documenta, no toques nada. No → sigue.
  2. ¿Tu proyecto está en plan gratuito y lleva horas sin tráfico? Sí → sospecha pausa por inactividad antes que agotamiento. No → sigue.
  3. ¿El número se queda alto con el tráfico parado? Sí → fuga: ve a cerrar transacciones y clientes en el código. No → sigue.
  4. ¿Tu cadena de aplicación usa el puerto 5432 directo? Sí → causa 1: muévela al 6543. No → sigue.
  5. ¿Estás en modo sesión con carga web corta? Sí → causa 2: cambia a modo transacción y desactiva lo que no sobreviva. No → sigue.
  6. ¿(pool local + overflow) × instancias supera el 80% del aforo? Sí → causa 4: reduce el reparto local, no el del intermediario. No → sigue.
  7. Nada de lo anterior y la espera en cola supera 50 ms sostenidos → concurrencia real: es el momento de hablar de tamaño de instancia.
Terminal con consulta SQL a pg_stat_activity mostrando sesiones agrupadas por estado

El tratamiento según el origen del problema

Esta sección es la más larga a propósito, porque es donde la mayoría de artículos sobre connection pooling se quedan en «usa el pooler» y te dejan solo con el resto.

Ajustar el dimensionado y el modo sin sobredimensionar

Primer movimiento: mueve el tráfico de aplicación al puerto 6543 y deja el 5432 directo para migraciones y herramientas de administración. Esto solo, en 7 de nuestros 23 casos, resolvió el incidente por completo.

Segundo movimiento: dimensiona con la ley de Little en lugar de a ojo. Si atiendes 200 peticiones por segundo y cada una retiene el canal 25 milisegundos, necesitas 200 × 0,025 = 5 plazas de media. Dobla ese número para absorber picos y tienes 10. No 100.

Fuerte es la tentación de subir a 100, y casi siempre contraproducente. Cien procesos de Postgres peleándose por la misma CPU rinden peor que veinte bien alimentados: aparece contención de locks, el planner compite por memoria y la latencia media empeora mientras el número de plazas «disponibles» sube. Hemos visto bajar la latencia p95 de 890 ms a 210 ms reduciendo de 80 a 20.

Tercer movimiento: pon un idle_in_transaction_session_timeout de 30 segundos y un statement_timeout acorde a tu peor consulta legítima. Es un cortafuegos, no una solución, pero convierte una caída en una degradación. Y una degradación la explicas; una caída la sufres.

Configurar el cliente para entornos serverless y edge

En funciones efímeras la regla cambia de signo: el reparto local debe ser mínimo, idealmente 1 o 2, y la multiplexación de verdad vive en Supavisor. Cualquier otra cosa es multiplicar repartos por instancias.

Con Prisma, la URL de runtime va al puerto de transacción con pgbouncer=true&connection_limit=1, y DIRECT_URL apunta al acceso directo para que prisma migrate siga funcionando. Dos variables, no una. Este detalle se nos escapó una vez y el deploy de migraciones falló doce veces seguidas antes de que cayéramos.

¿Y el cliente JavaScript sobre API REST? Ese no consume plazas del motor por sí mismo, pasa por PostgREST, que gestiona su propio reparto interno. Mucha gente mezcla ambos caminos y acaba contando dos veces el mismo recurso.

Para Cloudflare Workers y runtimes edge, instancia el cliente en el scope del módulo y reutilízalo entre invocaciones del mismo isolate. Crear uno dentro del handler es exactamente el error que describimos antes.

Qué resolver en el código antes de subir de plan

Checklist que aplicamos antes de autorizar cualquier cambio de instancia:

  • Cliente instanciado una sola vez, fuera del handler de petición
  • Todo connect() con su release() en bloque finally
  • Transacciones cerradas con commit o rollback explícito en cada rama del código
  • Consultas pesadas fuera del ciclo de petición, en colas o trabajos programados
  • Herramientas de BI y notebooks con usuario propio y cupo limitado
  • application_name poblado en cada servicio para poder auditar

Dicho sin diplomacia: subir de plan con una fuga activa es comprar más tiempo hasta la siguiente caída, y ese tiempo cuesta un escalón de factura permanente que en los tramos habituales se mueve entre decenas y pocos cientos de euros al mes. Cuando el presupuesto de infraestructura empieza a crecer por motivos que nadie sabe explicar, nuestra auditoría técnica para revisar tu repositorio suele devolver más ahorro que cualquier optimización de hardware, porque los patrones defectuosos se replican por copia y pega entre servicios durante meses sin que nadie los cuestione.

Rack de servidores que ilustra el límite físico de memoria por proceso en Postgres

Cuándo el límite ya no es configuración sino tamaño de instancia

Hay un punto en que escalar es la respuesta correcta, y confundirlo con mala configuración también cuesta dinero. Tres señales lo marcan.

La primera: estás en modo transacción, sin fugas detectables, con el dimensionado calculado por la ley de Little, y aun así el tiempo de espera en cola supera los 50 ms de forma sostenida. Eso es demanda real.

Segunda señal: la CPU del motor pasa del 70% durante los picos. Si el cuello está en cómputo, recortar plazas no arregla nada porque el trabajo sigue estando ahí.

Aritmética simple es la tercera. Si tu tráfico legítimo necesita 40 plazas activas y tu instancia tiene un aforo de 60, no hay configuración posible que te deje margen operativo. Toca subir.

Nos encontramos esto en un proyecto de telemetría industrial con 1.800 dispositivos escribiendo cada 10 segundos: 180 escrituras por segundo con lotes de 300 ms de ocupación daban 54 plazas necesarias. Ahí el salto de Small a Large no era pereza, era la única salida honesta. Cuando el escalado vertical se vuelve recurrente cada pocos meses, merece la pena evaluar si nuestro planteamiento de migrar a coste fijo sale mejor que seguir subiendo escalones de un servicio gestionado, porque a partir de cierto volumen la aritmética cambia de lado y el ahorro anual compensa el esfuerzo de la transición.

Resumiendo el protocolo entero en una frase: diagnostica antes de configurar, configura antes de pagar. Quedarse sin conexiones es casi siempre un síntoma de que alguien, en algún momento, copió una cadena del panel y se olvidó de volver a ella. No es un fallo de la plataforma, y conviene comprobarlo, no asumirlo, y por eso mismo tiene arreglo esta misma tarde.

Founder & Software Engineer
Ingeniero de Software y Desarrollador Full Stack con más de 10 años de experiencia operativa. Dirige MVPtoSaaS aplicando los mismos estándares de fiabilidad y rendimiento exigidos en sistemas industriales y de alta criticidad. El enfoque de Felipe asegura que cada producto que transita por MVPtoSaaS cuente con código limpio, bases de datos optimizadas para alta concurrencia y despliegues automatizados preparados para el crecimiento sostenido sin deuda técnica.