Skip to content

Postgres over HTTP for Vercel, Workers, and Lambda: the Neon serverless driver now works on Layerbase

7 min readPostgreSQLServerlessLayerbase

Until this week, the honest line in our serverless Postgres comparison was "we do not have an HTTP driver for Postgres, and if edge-runtime Postgres is your requirement, Neon's driver is the reason to pick them." That line is gone, and the way it went is the interesting part: we did not write a driver. Every Layerbase Postgres now answers the HTTP protocol that Neon's driver already speaks.

ts
import { neon } from '@neondatabase/serverless'

const sql = neon(process.env.DATABASE_URL) // your Layerbase connection string

const users = await sql`select * from users where id = ${id}`

That is the whole integration. The connection string is the same one the dashboard shows you. There is no flag to turn on, no second hostname, no @layerbase/serverless package to learn.

Contents

Why an HTTP driver matters

Postgres speaks a binary protocol over a long-lived TCP socket. Three kinds of runtime have a problem with that.

Runtimes that cannot open a socket at all. Cloudflare Workers and Vercel Edge Functions give you fetch and little else. A normal pg client does not load there, and a database without an HTTP face is simply unreachable from them.

Runtimes that can open a socket but should not hold one. AWS Lambda, Vercel Functions, Cloud Run. Each cold start is a new process, each process opens its own connection, and a traffic spike turns into hundreds of backends trying to connect at once. On RDS this is the problem RDS Proxy exists to solve, at an hourly price, inside a VPC you now have to configure your function into.

Runtimes behind a small set of shared NAT addresses. A fleet of functions egressing through a handful of IPs looks, to a per-IP connection-rate limit, like one extremely impatient client.

An HTTP query endpoint answers all three: every request is a fetch, there is no socket to hold between invocations, and the server side can pool on your behalf.

Why we implemented Neon's protocol instead of our own

We could have published @layerbase/serverless. We chose not to, for a reason that has nothing to do with engineering effort.

A driver is only useful once the ecosystem around it exists: the Drizzle adapter, the Prisma adapter, the Kysely dialect, the framework templates, the thousand Stack Overflow answers. @neondatabase/serverless already has all of that. Drizzle ships drizzle-orm/neon-http. Prisma ships @prisma/adapter-neon. Kysely has kysely-neon. @vercel/postgres is a thin wrapper over it. PlanetScale made the same call for their Postgres product earlier this year, and it was the right one: meet the driver people already have in package.json rather than ask them to install yours.

We already do this twice elsewhere. Redis and Valkey databases on Layerbase expose an Upstash-compatible REST endpoint so @upstash/redis and @vercel/kv work as-is; MySQL and MariaDB expose a PlanetScale-compatible endpoint so @planetscale/database works as-is. Postgres was the conspicuous hole.

There is also a detail of the Neon driver that made this unusually clean. The driver decides where to send its HTTP requests by taking the hostname from your connection string and replacing the first label with api.: ep-cool-name.us-east-2.aws.neon.tech becomes api.us-east-2.aws.neon.tech. So mydb-cool-darkness.cloud.layerbase.dev becomes api.cloud.layerbase.dev, which our wildcard DNS record and wildcard certificate already served. We added an endpoint at /sql on that host. No per-database DNS, no extra certificates, no new hostname in your connection string.

What works

Everything the HTTP mode of the driver does against Neon:

  • Tagged-template queries with parameters. Values are sent as Postgres parameters, never interpolated.
  • sql.query(text, params) for dynamic SQL with $1 placeholders.
  • Typed results. The proxy returns raw Postgres text and the driver parses it with the same pg-types it uses against Neon, so int4 comes back as a number, timestamptz as a Date, jsonb as an object, and your custom types config is honored.
  • fullResults and arrayMode options, per call or per client.
  • sql.transaction([...]) with isolationLevel, readOnly, and deferrable. The batch runs as a single BEGIN ... COMMIT on one connection and rolls back if any statement fails.
  • Errors as NeonDbError with code, detail, hint, and position, so error.code === '23505' keeps meaning a unique violation.
  • Drizzle neon-http, @vercel/postgres, and the Prisma and Kysely Neon adapters, which all sit on the same calls.
  • Hibernated databases. A free database that has been idle for an hour wakes on the first HTTP request, the same way it wakes on the first TCP connection.
ts
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'

const sql = neon(process.env.DATABASE_URL)
export const db = drizzle(sql)

Under the hood each request runs through the database's PgBouncer in transaction mode, which is the same pooler behind the pooled TCP connection string. A thousand concurrent Lambda invocations become a thousand short HTTP requests against a pool of twenty backend connections, which is what a pooler is for.

What does not work, and why

Two things, and we would rather you read them here than discover them mid-deploy.

Interactive transactions over WebSocket. The Neon driver has a second mode, Pool and Client from the same package, that tunnels the real Postgres protocol over a WebSocket so you can BEGIN, run application logic, and COMMIT across multiple round trips. We have not implemented that proxy. If you need an interactive transaction, use sql.transaction([...]) when the statements are known up front, or a normal TCP driver (pg, postgres.js) over the pooled connection string from a runtime that can open a socket. Multi-round-trip transactions from a Worker are the one case where Neon still has something we do not.

Databases with client-certificate (mTLS) enforcement. On the Pro plan you can require a client certificate on every connection. An HTTP request cannot present one, so the HTTP endpoint refuses mTLS-enforced databases with a clear error rather than quietly becoming a password-only side door. That refusal is the feature working, not a gap; if you want both, use the @layerbase/deno-mtls path described in Postgres mTLS client certificates.

One smaller thing: the endpoint accepts request bodies up to 1 MiB. A single query larger than that should be a COPY over TCP anyway.

How it is built

For the curious, since the design is short enough to describe.

The proxy lives inside the cloud API as POST /sql, routed by path regardless of hostname, so both the derived api.cloud.layerbase.dev endpoint and a custom neonConfig.fetchEndpoint pointed at the database's own hostname land in the same handler. It reads the connection string from the Neon-Connection-String header, resolves the database by hostname, and compares the username and password in constant time. A wrong password produces the same 28P01 password authentication failed body Postgres itself would send, and it never dials the database. The same guards the rest of the platform applies, suspended accounts, databases locked by a plan change, accounts mid-migration, apply here with the same HTTP statuses.

A passing request opens one pg client to the database's pooler port on the box, runs the query in array mode with identity type parsers so the wire text reaches the driver unchanged, and closes it. Batches run BEGIN, the statements in order, and COMMIT on that one client, with ROLLBACK on the first error. Response bodies match the driver's expectations field for field: fields, rows, command, rowCount, rowAsArray, and { results: [...] } for a batch.

The test suite does not mock the protocol. It installs the real @neondatabase/serverless package and points its fetchFunction at the proxy, so typed row parsing, batch transactions, isolation headers, and NeonDbError codes are asserted against the driver Neon ships, not against our reading of it. We learned the hard way elsewhere that a green test against a hand-written client proves very little.

Lambda without RDS Proxy

The tutorial shape this replaces is familiar: put the Lambda in the VPC, add a NAT gateway so it can still reach the internet, stand up RDS Proxy so the connection count survives a spike, store the password in Secrets Manager, wire the IAM role. Each step is reasonable and the sum is an afternoon.

The equivalent here is one environment variable and four lines of code, from a function that stays outside any VPC:

ts
import { neon } from '@neondatabase/serverless'

const sql = neon(process.env.DATABASE_URL)

export const handler = async (event) => {
  const [order] = await sql`select * from orders where id = ${event.orderId}`
  return { statusCode: 200, body: JSON.stringify(order) }
}

The pooler is the connection broker. The connection string is the credential, and it is the same one every other client uses. There is nothing to keep warm.

Try it

Create a Postgres database on the free tier, copy the connection string, and paste it into neon(). The serverless and edge guide has the Drizzle and Worker snippets, and the Connect dialog in the dashboard now shows the driver snippet next to psql and pg.

If you are on Neon today and the driver was the reason you stayed, the migration guide copies the schema and data in one read-only pass, and your application code does not change.