Saltar al contenido
AIM Desarrollo y Automatizaciones

De ~1,25 s a una fracción: Neon por HTTP desde Cloudflare

Cada petición a la API abría una conexión TCP y TLS a Neon. Con el driver HTTP, cada consulta es una petición. Así lo medí, lo probé y lo puedo revertir.

Publicado el
Tiempo de lectura
5 min de lectura
  • Rendimiento
  • Neon
  • Cloudflare
  • Postgres
  • Pruebas

La API de este sitio corre en Cloudflare Pages Functions y guarda sus datos en Neon, un Postgres serverless en aws-us-east-1: contactos, login del panel, límites del asistente, auditoría y el límite de tasa de Seguridad en acción. Funcionaba, pero lento: en producción, una petición con una transacción pequeña tardaba unos 1,25 s, y 2,1 s con Neon dormido.

Te cuento por qué, qué comparé, qué perdí al cambiar de driver y cómo lo pruebo y lo revierto (DEC-027).

Por qué pg era lento: los saludos antes de la primera consulta

Un Worker de Cloudflare no puede compartir conexiones entre peticiones, así que cada una abría la suya con pg (node-postgres): TCP, TLS y autenticación, unos 7 viajes de ida y vuelta hasta us-east-1 antes de la primera consulta. Desde Colombia, cada viaje se nota.

El límite del Lab sumaba cuatro viajes más: BEGIN, un candado, la sentencia y COMMIT. La consulta en sí era lo de menos.

Las opciones que comparé

  • El driver serverless de Neon por HTTP (@neondatabase/serverless con drizzle-orm/neon-http): cada sentencia es una petición HTTPS, sin conexión que abrir.
  • El mismo driver por WebSocket: conserva las transacciones, pero abre un WebSocket por petición: en mis medidas, 486 ms el Lab y 357 ms el contacto.
  • Cloudflare Hyperdrive: me deja seguir con pg, pero exige configurarlo en la cuenta, con la caché apagada porque los límites son SELECT, y hace un viaje por sentencia: unos 290 ms estimados para el Lab.
  • Mantener Neon despierto: descartado. Respeto el escalado a cero.

Medido desde Colombia el 2026-11-08, contra una rama temporal de Neon (mediana de 15 peticiones), el driver HTTP frente a pg:

  • Lab: de 683 ms a 85 ms (211 ms si cada petición abre su propia conexión TLS).
  • Contacto: de 551 ms a 163 ms.
  • Límite del asistente: de 568 ms a 162 ms.
  • Código de login: de 682 ms a 323 ms.
  • Lab con Neon dormido: de 1,35 s a 0,87 s.

Lo que perdí: las transacciones interactivas

El driver HTTP no tiene transacciones interactivas: no puedo abrir una, leer un resultado en JavaScript y decidir la siguiente sentencia. Sí puede mandar varias sentencias en una petición y confirmarlas juntas. Lo dejé explícito en el tipo, para que nadie use transaction por costumbre:

export type Db = Omit<PgDatabase<PgQueryResultHKT, typeof schema>, 'transaction'>

Lo que debe confirmarse junto va en una sola sentencia o en transactionBatch, en apps/edge/src/db/client.ts. Con Neon es una sola petición; con pg y con PGlite, las mismas sentencias entre BEGIN y COMMIT:

if (is(db, NeonHttpDatabase)) {
  const [first, ...rest] = statements.map((statement) => db.execute<Row>(statement))
  if (!first) return []
  const results = await db.batch([first, ...rest])
  return (results as { rows: Row[] }[]).map((result) => result.rows)
}

Hoy solo lo necesita el límite del Lab.

El candado del Lab, con 15 peticiones a la vez

El límite de Seguridad en acción acepta 10 peticiones por ventana y por IP (guarda una huella, nunca la IP). Si llegan muchas a la vez, todas podrían leer “llevo 9” y colarse. Por eso, en lab/burst.ts, el lote empieza con un candado de Postgres por IP que dura lo que dura la transacción:

const [, rows] = await transactionBatch(db, [
  sql`select pg_advisory_xact_lock(hashtextextended(${`lab_burst:${ipHash}`}, 0))`,

Detrás va una sentencia que borra las filas viejas de esa IP, cuenta las de la ventana y guarda esta petición solo si cabe. Los cuatro viajes de antes son ahora una petición HTTP.

¿Sigue funcionando el candado sin transacción interactiva? Una prueba local manda 15 peticiones a la vez y exige que pasen exactamente 10; otra lo repite contra una rama real de Neon:

const responses = await Promise.all(
  Array.from({ length: 15 }, () => t.request('/api/lab/burst', { ip, origin: null })),
)
const statuses = responses.map((r) => r.status)

expect(statuses.filter((s) => s === 200)).toHaveLength(10)
expect(statuses.filter((s) => s === 429)).toHaveLength(5)

Contra la rama de Neon pasaron exactamente 10. Sin el candado pasaban las 15.

Pruebas sin red: un Neon de mentira sobre PGlite

No quería pruebas que dependieran de Neon ni de internet, pero tampoco probar con otro driver que el de producción. La solución fue un sustituto del endpoint HTTP de Neon, neon-http-fake.ts, que responde con PGlite, un Postgres en WebAssembly que corre dentro de la prueba. El driver de Neon deja cambiar su fetch, y ahí entra el sustituto:

neonConfig.fetchFunction = async (endpoint: string, init: RequestInit) => {
  const headers = new Headers(init.headers)
  const host = new URL(headers.get('Neon-Connection-String') ?? '').hostname
  // Any other host is a real Neon branch (test/neon-live.test.ts): its requests go out untouched.
  if (!databases.has(host)) return fetch(endpoint, init)

Habla el protocolo del driver: una sentencia sola va en autocommit, un lote corre en una transacción con ROLLBACK al primer error, y un error de Postgres vuelve como un 400 con su código, como en Neon. Así, toda la API se prueba con el driver de producción. transactionBatch y el límite del Lab corren además con el driver propio de PGlite, que tiene transacciones interactivas como el pg de desarrollo. Otra prueba exige que un lote de tres sentencias sea una sola petición HTTP.

Antes de publicar un cambio en esta capa, pnpm --filter @aim/edge test:neon repite los flujos contra una rama temporal de Neon (nunca la de producción: escribe filas).

El interruptor para volver atrás

En local sigo con pg, porque el Postgres de Docker no habla el protocolo HTTP de Neon. Una variable decide el driver:

export function databaseDriver(env: Pick<EdgeEnv, 'APP_ENV' | 'DATABASE_DRIVER'>): DatabaseDriver {
  return env.DATABASE_DRIVER ?? (env.APP_ENV === 'development' ? 'pg' : 'neon-http')
}

Producción no necesitó configuración nueva: el secreto DATABASE_URL es el mismo. Volver a pg es agregar DATABASE_DRIVER=pg a las variables de wrangler.jsonc y publicar otra vez, sin tocar código.

En producción

Después de publicar, medí con curl desde mi equipo en Colombia, el 2026-11-08, contra GET /api/lab/burst, el endpoint de Seguridad en acción, que ejecuta la transacción del límite contra Neon:

  • Antes, con pg: tres peticiones seguidas tardaron 1,57 s, 1,60 s y 2,49 s.
  • Después, con el driver HTTP, 11 peticiones seguidas desde la misma IP. La primera tardó 1,61 s: Neon estaba dormido y el Worker arrancó en frío. De la 2 a la 10, todas respondieron 200, con una mediana de 0,133 s (entre 0,125 s y 0,320 s). La 11 recibió un 429, como debía: el límite es de 10 por ventana, y sigue siendo exacto.

Lo digo tal cual: un solo cliente y un puñado de peticiones, con tiempos vistos desde mi lado, red desde Colombia incluida. No es un benchmark; la comparación detallada es la de arriba.

Quedan dos costos. Con Neon dormido, la primera petición sigue pagando unos 0,65 s de arranque. Y cada consulta HTTP cuenta como subpetición del Worker: el plan gratuito permite 50; la ruta más larga usa unas 6.

Para revisarlo tú

Y para verlo desde tu navegador, Seguridad en acción envía 15 peticiones seguidas contra ese límite y te muestra cuáles pasan.