This site’s API runs on Cloudflare Pages Functions and stores its data in Neon, a serverless Postgres in aws-us-east-1: contacts, the admin login, the assistant’s limits, the audit log and the rate limit behind Security in action. It worked, but it was slow: in production, a request with a small transaction took about 1.25 s, and 2.1 s when Neon was asleep.
Here’s why, what I compared, what I gave up by switching drivers, and how I test it and roll it back (DEC-027, in Spanish).
Why pg was slow: the handshakes before the first query
A Cloudflare Worker can’t share connections across requests, so each request opened its own with pg (node-postgres): TCP, TLS and authentication, about 7 round trips to us-east-1 before the first query. From Colombia, every round trip shows.
The Lab’s rate limit added four more round trips: BEGIN, a lock, the statement and COMMIT. The query itself was the least of it.
The options I compared
- Neon’s serverless driver over HTTP (
@neondatabase/serverlesswithdrizzle-orm/neon-http): every statement is one HTTPS request, with no connection to open. - The same driver over WebSocket: it keeps transactions, but opens a WebSocket per request: in my measurements, 486 ms for the Lab and 357 ms for a contact.
- Cloudflare Hyperdrive: it lets me keep
pg, but it needs to be set up in the account, with the cache off because the limits areSELECTs, and it makes one round trip per statement: about 290 ms estimated for the Lab. - Keeping Neon awake: ruled out. I respect scale to zero.
Measured from Colombia on 2026-11-08, against a temporary Neon branch (median of 15 requests), the HTTP driver versus pg:
- Lab: from 683 ms to 85 ms (211 ms if every request opens its own TLS connection).
- Contact: from 551 ms to 163 ms.
- Assistant limit: from 568 ms to 162 ms.
- Login code: from 682 ms to 323 ms.
- Lab with Neon asleep: from 1.35 s to 0.87 s.
What I gave up: interactive transactions
The HTTP driver has no interactive transactions: I can’t open one, read a result in JavaScript and decide the next statement. It can send several statements in one request and commit them together. I made that explicit in the type, so nobody reaches for transaction out of habit:
export type Db = Omit<PgDatabase<PgQueryResultHKT, typeof schema>, 'transaction'>
Whatever must commit together goes in a single statement or in transactionBatch, in apps/edge/src/db/client.ts. With Neon it’s one request; with pg and PGlite, the same statements run between BEGIN and 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)
}
Today only the Lab’s rate limit needs it.
The Lab’s lock, with 15 requests at once
The rate limit behind Security in action accepts 10 requests per window per IP (it stores a hash, never the IP). If many arrive at once, they could all read “9 used” and slip through. That’s why, in lab/burst.ts, the batch starts with a Postgres lock per IP that lasts as long as the transaction:
const [, rows] = await transactionBatch(db, [
sql`select pg_advisory_xact_lock(hashtextextended(${`lab_burst:${ipHash}`}, 0))`,
After it comes one statement that deletes that IP’s old rows, counts the ones in the window and stores this request only if it fits. The four round trips from before are now one HTTP request.
Does the lock still work without an interactive transaction? A local test sends 15 requests at once and requires exactly 10 to pass; another one repeats it against a real Neon branch:
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)
Against the Neon branch, exactly 10 passed. Without the lock, all 15 did.
Tests without a network: a fake Neon backed by PGlite
I didn’t want tests that depend on Neon or the internet, nor tests with a driver other than production’s. The answer was a stand-in for Neon’s HTTP endpoint, neon-http-fake.ts, answered by PGlite, a Postgres in WebAssembly that runs inside the test. Neon’s driver lets you swap its fetch, and that’s where the fake comes in:
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)
It speaks the driver’s protocol: a single statement runs in autocommit, a batch runs in one transaction with a ROLLBACK on the first error, and a Postgres error comes back as a 400 with its code, as with Neon. So the whole API is tested with production’s driver. transactionBatch and the Lab’s rate limit also run with PGlite’s own driver, which has interactive transactions like the pg of local development. Another test requires a batch of three statements to be a single HTTP request.
Before I publish a change to this layer, pnpm --filter @aim/edge test:neon repeats the flows against a temporary Neon branch (never the production one: it writes rows).
The switch to roll it back
Locally I stay on pg, because the Docker Postgres doesn’t speak Neon’s HTTP protocol. One variable picks the driver:
export function databaseDriver(env: Pick<EdgeEnv, 'APP_ENV' | 'DATABASE_DRIVER'>): DatabaseDriver {
return env.DATABASE_DRIVER ?? (env.APP_ENV === 'development' ? 'pg' : 'neon-http')
}
Production needed no new configuration: the DATABASE_URL secret is the same. Going back to pg means adding DATABASE_DRIVER=pg to the variables in wrangler.jsonc and publishing again, without touching code.
In production
After publishing, I measured with curl from my machine in Colombia, on 2026-11-08, against GET /api/lab/burst, the Security in action endpoint, which runs the rate limit’s transaction against Neon:
- Before, with
pg: three requests in a row took 1.57 s, 1.60 s and 2.49 s. - After, with the HTTP driver, 11 requests in a row from the same IP. The first one took 1.61 s: Neon was asleep and the Worker started cold. Requests 2 to 10 all answered 200, with a median of 0.133 s (between 0.125 s and 0.320 s). The 11th got a 429, as it should: the limit is 10 per window, and it’s still exact.
To be clear: one client and a handful of requests, timed from my side, network from Colombia included. It isn’t a benchmark; the detailed comparison is the one above.
Two costs remain. With Neon asleep, the first request still pays about 0.65 s to wake it up. And every HTTP query counts as a Worker subrequest: the free plan allows 50; the longest route uses about 6.
Check it yourself
- The decision and its measurements:
docs/DECISIONES.md(DEC-027, in Spanish). - The driver,
transactionBatchand the switch:apps/edge/src/db/client.ts, with its tests indb-client.test.ts. - The Lab’s rate limit and its tests:
lab/burst.tsandlab-burst.test.ts. - The test against real Neon:
docs/DESPLIEGUE.md(in Spanish) andneon-live.test.ts.
And to see it from your browser, Security in action sends 15 requests in a row against that rate limit and shows you which ones get through.