|

prepare false postgres-js: la config de 20 líneas en Vercel

En pocas palabras: Con el pooler de Supabase en modo transacción (puerto 6543), postgres-js necesita prepare: false porque ese modo no soporta prepared statements, y max: 1 porque su default de 10 conexiones se multiplica por cada instancia serverless que levanta Vercel.

Con el pooler de Supabase en modo transacción (puerto 6543), usar prepare: false en postgres-js es obligatorio: postgres-js crea prepared statements por defecto y ese modo no los soporta. Sin la opción aparece el error prepared statement “stmt_1” already exists cuando dos requests se solapan.

Postgres.js es un cliente de PostgreSQL para Node.js y Deno que arma las queries con tagged template strings. La opción prepare: false desactiva la creación automática de prepared statements, que el driver trae activada (prepare: true). Un pooler en modo transacción, como el de Supabase en el puerto 6543, es un intermediario que devuelve la conexión al pool al terminar cada transacción.

En 30 segundos

  • prepare: false va siempre que tu cadena de conexión use el transaction pooler de Supabase (puerto 6543), porque postgres-js trae prepare: true por defecto.
  • max: 1 limita el pool de cada instancia serverless. El default de postgres-js es 10 y se multiplica por las instancias que levante Vercel.
  • El modo transacción también deja afuera query pipelining, cursores fuera de una transacción y estado de sesión (SET, advisory locks de sesión, LISTEN/NOTIFY, tablas temporales).
  • El caso que origina esta nota, publicado el 15 de octubre de 2026 por el equipo de Notifio, es un cliente de 20 líneas, una sola tabla y un webhook de Stripe idempotente gracias a columnas UNIQUE.

¿Por qué falla prepared statement “stmt_1” already exists con el pooler en modo transacción?

Falla porque un prepared statement tiene nombre y vive en una conexión puntual con Postgres, y el pooler de Supabase en modo transacción devuelve esa conexión al pool al terminar cada transacción. El próximo request puede caer en un backend que nunca vio stmt_1, o en uno donde otro request ya creó su propio stmt_1.

Escenario hipotético: subís el cliente, lo probás en local con un request por vez, anda perfecto, lo mandás a Vercel, llega el primer pico de tráfico, dos requests se pisan en el pooler y de golpe aparece un stmt_1 que nadie creó, ni en tus logs ni en tu código.

Según el post del equipo de Notifio, con un request por vez quizás nunca lo veas. Llega la primera vez que dos se solapan, o sea, justo cuando importa.

La guía de conexión de Supabase lo dice sin vueltas (traducción nuestra): el modo transacción no soporta prepared statements y, para evitar errores, hay que desactivarlos en la librería de conexión.

Ahora bien, hay un matiz de fechas. El anuncio de Supavisor 1.0 (diciembre de 2023) comunicó soporte para prepared statements con nombre, replicando cada PREPARE en todas las conexiones. La guía actual, consultada en octubre de 2026, igual indica apagarlos en modo transacción. Lo que no pude confirmar es bajo qué configuración exacta otros poolers los aceptan (el post de Notifio menciona que el README de postgres-js pide apagarlos con PgBouncer en modo transacción). Lo documentado hoy es apagarlos.

¿Qué hace prepare false en postgres-js y cómo se configura?

prepare false postgres-js diagrama explicativo

prepare: false le indica a postgres-js que no cree prepared statements de forma automática. El default es prepare: true, descrito en la tabla de opciones del repositorio de postgres-js como creación automática de prepared statements. Sin statements con nombre atados a una conexión, el próximo request ya no depende del backend que le toque (lectura nuestra del mecanismo).

La configuración que recomienda Supabase para una función serverless es esta:

import postgres from 'postgres'

export const sql = postgres(process.env.DATABASE_URL, {
 max: 1,
 prepare: false,
 ssl: 'require',
})

Cada driver lo llama distinto, según la tabla de Supabase:

  • Postgres.js y Drizzle: prepare: false en el cliente postgres().
  • Prisma: pgbouncer=true en la cadena de conexión.
  • asyncpg: statement_cache_size=0.
  • JDBC: prepareThreshold=0.

Notifio usa Drizzle encima de postgres-js, así que la opción va en la llamada a postgres(), no en drizzle(). Cubrimos ese tema en detalle en desplegar una app React con Vite.

¿Para qué sirve max: 1 en postgres-js dentro de Vercel?

max: 1 fija en una conexión el pool que cada instancia serverless abre hacia el pooler. postgres-js trae max: 10 por defecto, y Supabase aclara que el pool de tu librería es propio, separado de los poolers que corre Supabase, con defaults pensados para un backend persistente.

Cuentas hipotéticas: si Vercel levanta 30 instancias calientes, el default te deja con hasta 300 conexiones potenciales; con max: 1 son 30. Supabase advierte que unas pocas docenas de instancias alcanzan para agotar el pool, y que no controlás cuántas hay.

El README de postgres-js agrega que el orden de ejecución solo está garantizado con sql.begin() o con max: 1. Esa salvedad aplica a queries lanzadas en paralelo sin await; una secuencia de queries con await se comporta igual con cualquier tamaño de pool. Para este caso, entonces, max: 1 es sobre todo control del tamaño del pool.

Una inferencia mía: con una sola conexión, un Promise.all con varias queries en el mismo handler se encola. Supabase sugiere subir el máximo solo con evidencia de que las invocaciones concurrentes de una misma instancia están esperando conexión.

¿Qué más deja de soportar el modo transacción además de los prepared statements?

Supabase documenta cuatro limitaciones del modo transacción: prepared statements, query pipelining, cursores fuera de una transacción y estado de sesión. Las tres que no son prepared statements se descubren en producción, y casi siempre un viernes. Complementá con publicar tu web gratis en Vercel.

  • Query pipelining: el pooler suelta la conexión apenas termina una respuesta, aunque el cliente ya haya encolado más queries. Supabase avisa que postgres.js hace pipelining por defecto y que la combinación puede colgar queries o devolver filas que no corresponden. Para evitarlo remite a su guía de Postgres.js, que no revisé.
  • Cursores: funcionan dentro de una sola transacción. Un cursor with hold no sobrevive a la devolución de la conexión.
  • Estado de sesión: se pierden SET y RESET, advisory locks de sesión, LISTEN/NOTIFY y tablas temporales.

Notifio no sufre nada de esto, y según el autor no es suerte: toda la base es una tabla, licenses, con compras únicas y sin facturación recurrente. “¿Está desbloqueado?” se resuelve leyendo una columna, no comparando fechas contra un período. Si tu código usa LISTEN, advisory locks o tablas temporales, este pooler no te sirve; Supabase manda a session mode o conexión directa.

Si tu backend es persistente, por ejemplo en un VPS de donweb.com, la tabla de Supabase recomienda conexión directa (con IPv6 o el add-on IPv4) o session mode si la red es solo IPv4. El transaction pooler es para serverless y edge.

¿Cómo hacer idempotente un webhook de Stripe con restricciones UNIQUE en Postgres?

Se logra con columnas UNIQUE y un insert que tolera perder la carrera: la base rechaza el duplicado y el request perdedor relee la fila ganadora y entrega el mismo token. En Notifio son unique email, token, stripe_session_id y auto_reply_session_id.

El handler convierte un Checkout Session completado en una fila de licencia, una sola vez, aunque Stripe reenvíe el evento. Lee por session id, después por email, e inserta. El insert va dentro de un catch que no da el error por fatal (código adaptado del post original):

try {
 await db.insert(licenses).values({
 id: createId(), email, token,
 stripeSessionId: session.id, active: true
 });
} catch (err) {
 // Un webhook concurrente ganó la carrera y insertó primero
 const [row] = await db.select().from(licenses)
 .where(eq(licenses.email, email)).limit(1);
 if (!row) throw err; // error real de base: 500 y Stripe reintenta
 token = row.token;
}

La línea if (!row) throw err pesa tanto como el fallback. Tragarse un error real de base devolvería un éxito sin licencia. El autor remarca que la constraint es la idempotencia, no el código que la rodea, y que en todo el repo no hay una sola transacción.

Dos propuestas editoriales, que no vienen del post: chequear que el error sea una violación de unicidad (SQLSTATE 23505) antes de caer al re-select, y evaluar INSERT … ON CONFLICT DO NOTHING como alternativa. Con la primera evitás que un error de otra naturaleza, con una fila existente para ese email, termine entregando un token ajeno al intento. Esto se conecta con lo que analizamos en comparativa entre Vercel y Amplify.

¿Qué hacer cuando la base de datos no responde en una app serverless?

Poné un connect_timeout corto y respondé 503 con Retry-After en las rutas que dependen de la base. Con connect_timeout: 10, una base caída produce un rechazo en 10 segundos y no una función que corre hasta su límite. Notifio suma idle_timeout: 30 en la misma configuración.

Un proxy (lo que antes se llamaba middleware en Next.js) responde 503 a toda ruta que necesite la base y deja pasar solo una allowlist: /api/download, /api/contact y /api/webhooks/resend. La lista apunta para el lado correcto: una ruta nueva queda rechazada por defecto en vez de intentar hablar con una base que no contesta.

El 503 con Retry-After tiene otra ventaja. La app de escritorio lo lee como “probá más tarde” y no como “licencia inválida”, y Stripe reintenta los webhooks cuando se apaga el switch, así que ninguna compra se pierde en la ventana de mantenimiento.

Aparte, Supabase documenta que las plataformas serverless congelan la función entre requests y eso puede dejar sockets TCP viejos. Si ves errores CONNECT_TIMEOUT o queries colgadas hasta el límite de ejecución, ese es el sospechoso.

¿Para qué sirve el global _pgClient en desarrollo con Next.js?

Evita agotar tu propio límite de conexiones mientras desarrollás. Next.js recarga módulos en cada edición; sin el global, cada guardado crea otro cliente postgres() con su conexión, y tras una hora de trabajo se llena el límite desde una laptop.

En producción el módulo se evalúa una vez por instancia, así que el global no aporta nada y el código lo protege con NODE_ENV. El bloque declare global es solo para que TypeScript tolere la propiedad (un parche de lint con traje de declaración de tipos, dicho por el propio autor). Ya lo cubrimos antes en el costo real de PostgreSQL gestionado.

¿Cómo verificar que tu configuración con pooler aguanta?

Lo que sigue es una propuesta editorial, no viene de las fuentes y no la probamos nosotros. El post de Notifio es el relato de un solo proyecto con una tabla. ¿Alguien lo replicó de forma independiente? Todavía no que sepamos, y tampoco trae mediciones de rendimiento.

  • Reproducí el solapamiento: en staging, disparás unos 20 requests en paralelo (cifra de ejemplo) contra un endpoint con la cadena del puerto 6543, primero con prepare activo y después con prepare: false, y comparás los errores.
  • Buscá estado de sesión en el código: LISTEN, pg_advisory_lock, CREATE TEMP TABLE y SET. Si aparece alguno, revisá si el pooler es el correcto.
  • Reenviá un evento dos veces en paralelo: en un entorno de prueba, confirmá que queda una sola fila y que ambos requests devuelven el mismo token.
  • Simulá la base caída: con una cadena inválida, medí que la ruta responda 503 con Retry-After en torno al connect_timeout.

Errores comunes

  • Dejar el pool en el default de 10. Ese número se pensó para un servidor de larga vida. En serverless, ponelo en 1 y subilo solo si ves cola de conexiones.
  • Apagar prepared statements después del primer incidente. Si la cadena termina en 6543, la opción va antes de salir a producción.
  • Asumir que el pooler guarda estado de sesión. Un SET o un advisory lock de sesión se pierde entre transacciones; hacelo dentro de la transacción o cambiá de modo.
  • Usar una denylist de rutas para el modo mantenimiento. La ruta que agregues el mes que viene va a intentar tocar la base. Una allowlist rechaza por defecto.

Preguntas Frecuentes

¿En qué puerto está el transaction pooler de Supabase?

El shared pooler en modo transacción escucha en el puerto 6543, y el session mode en el 5432. La guía de Supabase indica copiar el host desde el diálogo Connect del dashboard, porque no se puede deducir de la región.

¿Hay que usar prepare: false en session mode o conexión directa?

No. Según Supabase, la conexión directa y el session mode soportan los cuatro elementos que el modo transacción no (prepared statements, pipelining, cursores y estado de sesión). La restricción es del modo transacción.

¿Cuál es el valor por defecto de max en postgres-js?

Es 10 conexiones. Supabase lo señala como demasiado alto para serverless porque se aplica a cada instancia caliente de la función, y recomienda max: 1.

¿Qué código HTTP conviene devolver cuando la base está caída?

Un 503 con el header Retry-After. En Notifio la app de escritorio lo interpreta como “intentá más tarde” y no como licencia inválida, mientras que un 500 genérico no distingue una falla temporal de un error real.

¿Cómo evito licencias duplicadas si Stripe reenvía un webhook?

Poné una restricción UNIQUE sobre stripe_session_id (y sobre email y token si corresponde). Si dos entregas llegan a la vez, una inserta y la otra falla; ese error se captura, se relee la fila ganadora y se devuelve su token.

Conclusión

Lo útil del caso Notifio es que muestra un cliente mínimo donde cada opción responde a una limitación documentada del pooler. Si tu cadena termina en 6543, ponés prepare: false y max: 1 antes de salir a producción. Revisás las otras limitaciones del modo transacción (pipelining, cursores, estado de sesión) y, si usás LISTEN, advisory locks o tablas temporales, cambiás de pooler.

Apoyá la corrección en constraints UNIQUE y reintentos idempotentes, no en estado de sesión, y decidí con anticipación qué rutas sobreviven sin base, con una allowlist. Eso sí: es el relato de un proyecto chico, así que usalo como punto de partida y corré las pruebas de la sección de verificación en tu staging.

Fuentes

Te puede interesar...