AWS 18 ago 2026 · 5 min de lectura

Lambda contra PostgreSQL: el día que te quedas sin conexiones

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

El error llega siempre en el peor momento: remaining connection slots are reserved for non-replication superuser connections. La aplicación llevaba semanas funcionando, nadie tocó el código de base de datos, y de repente un pico de tráfico deja media API devolviendo 500. Lo primero que hace todo el mundo es subir max_connections en la instancia. Funciona un rato, y luego vuelve a pasar. El problema no es el número: es que un pool de conexiones y una función efímera son dos ideas que se contradicen.

Un pool por entorno, no un pool por aplicación

Un pool existe para amortizar algo caro. Abrir una conexión a PostgreSQL cuesta: handshake TCP, TLS, autenticación, y el servidor levanta un proceso dedicado por conexión. En un backend de larga vida —un Nest en Fargate, por ejemplo— abres diez conexiones al arrancar y las reutilizas durante días. El coste se paga una vez.

En Lambda no existe ese "al arrancar" en el sentido que asumes. Hay un arranque por entorno de ejecución, y AWS crea tantos entornos como invocaciones concurrentes tengas. Si tu pico son 200 invocaciones a la vez, tienes 200 procesos Node independientes, cada uno con su propio pool. Un pool de 10 por entorno son 2.000 conexiones llamando a la puerta de una base de datos que aguanta 100.

La cuenta es de una simplicidad brutal:

conexiones = concurrencia de Lambda × tamaño del pool por entorno

Una db.t4g.medium ronda las 220 max_connections. Con 50 invocaciones concurrentes y un pool de 5 ya estás en el límite. Y a diferencia de un servidor, aquí la concurrencia no la decides tú: la decide el tráfico.

El fallo que multiplica el daño: abrir el pool dentro del handler

Antes de tocar infraestructura, mira dónde se crea el cliente. Este patrón es el que más veces me he encontrado, y convierte un problema de escala en un problema inmediato:

// MAL: un pool nuevo por invocación, y muchas conexiones quedan colgando
export const handler = async (event) => {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const { rows } = await pool.query('SELECT id FROM users WHERE email = $1', [event.email]);
  return rows[0];
};

Cada invocación abre un pool nuevo. Si además falta el end() —y casi siempre falta— la conexión queda ocupada hasta que PostgreSQL la recicla por timeout. Acabas de convertir cada petición en una fuga.

Lo correcto es sacarlo del handler, al ámbito del módulo. Ese código corre una vez por entorno de ejecución, y el entorno se reutiliza entre invocaciones mientras siga caliente:

// El pool vive en el entorno de ejecución y sobrevive entre invocaciones
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1,                       // un entorno atiende una invocación a la vez
  idleTimeoutMillis: 30000,     // suelta la conexión si el entorno queda ocioso
  connectionTimeoutMillis: 5000,
});

export const handler = async (event) => {
  const { rows } = await pool.query('SELECT id FROM users WHERE email = $1', [event.email]);
  return rows[0];
};

max: 1 sorprende a quien viene de servidores, pero es exactamente lo que quieres: un entorno de Lambda procesa una invocación cada vez, así que un pool de 10 son nueve conexiones que nunca trabajan en paralelo y que sí cuentan contra tu límite.

Reserved concurrency: el techo que sí controlas

Con max: 1, tu número de conexiones pasa a ser exactamente tu concurrencia. Y esa sí se puede acotar, función por función:

resource "aws_lambda_function" "api" {
  function_name                  = "api-handler"
  reserved_concurrent_executions = 40   # techo duro de 40 conexiones
  # ...
}

Es una decisión incómoda porque significa aceptar throttling: pasadas 40 invocaciones simultáneas, Lambda rechaza. Pero prefiero mil veces un error acotado en una función concreta que una base de datos que deja de aceptar conexiones y tumba de paso a los workers, las migraciones y el panel de administración, que no tenían nada que ver. Un límite no es una restricción: es decidir de antemano quién sufre primero cuando algo se rompe.

RDS Proxy: qué resuelve y qué no

RDS Proxy se sienta entre Lambda y la base de datos, y mantiene su propio conjunto de conexiones ya abiertas. Tus funciones hablan con el proxy, y él multiplexa: muchos clientes efímeros contra unas pocas conexiones reales reutilizadas.

Lo que gana de verdad:

  • Absorbe los picos. Cuando la base de datos está al límite, el proxy encola en vez de rechazar.
  • Elimina el coste de abrir conexión en cada arranque en frío. Se nota más de lo que parece en la latencia p99.
  • Failover más limpio. Mantiene viva la conexión del cliente mientras el motor conmuta.

Lo que conviene saber antes de meterlo:

  • No es gratis. Se factura por vCPU de la instancia de base de datos, y en cargas pequeñas puede salir más caro que subir de tamaño la propia base.
  • El pinning te arruina la fiesta. Si usas transacciones largas, sentencias preparadas a nivel de sesión, SET de variables o tablas temporales, el proxy fija una conexión a tu sesión y deja de multiplexar. Terminas pagando por un proxy que no reutiliza nada. Se ve en CloudWatch con DatabaseConnectionsCurrentlySessionPinned, y es lo primero que miro cuando alguien dice que el proxy "no hizo nada".
  • Vive dentro de la VPC. Lo que implica meter la Lambda en subredes privadas, con todo lo que eso arrastra en arranques y en factura de red.

Lo que haría hoy

El orden importa, y casi nadie lo respeta:

  1. Saca el pool del handler y pon max: 1. Cinco minutos, cero coste, y resuelve la mayoría de los casos.
  2. Pon reserved_concurrent_executions en las funciones que tocan la base de datos. También gratis, y convierte una caída total en una degradación acotada.
  3. Mide antes de comprar. Mira DatabaseConnections en CloudWatch durante un pico real. Si estás lejos del techo, no necesitas un proxy.
  4. Añade RDS Proxy cuando el tráfico sea genuinamente irregular y hayas comprobado que tus consultas no provocan pinning.

Y una nota que no es técnica: si el 90% de tu aplicación es un CRUD contra PostgreSQL con tráfico razonablemente constante, es probable que la respuesta correcta no sea ninguna de estas cuatro, sino un contenedor de larga vida con un pool normal y aburrido. Lambda es espectacular para trabajo a ráfagas. Contra una base relacional bajo carga sostenida, muchas veces estás pagando complejidad para resolver un problema que no tenías.

Yohangel Ramos

Escrito por Yohangel Ramos

Senior Fullstack Developer y Tech Lead. Construyo con React, Next.js, Nest.js y AWS — y escribo sobre lo que aprendo en el camino.

Hablemos →

Sigue leyendo

Frontend

Estados de carga: ese spinner centrado es una decisión de producto

AWS

CloudFront delante de tu app: por qué tu hit rate es del 30%