The serverless driver and the pooled URL
How serverless functions reach Neon Postgres without exhausting it: the pooled or unpooled connection string, and the Neon serverless driver's HTTP or WebSocket shape.
It’s launch day. Your invoices page is a Server Component : it runs on the server, reads the invoice list straight from Postgres, and streams the HTML back. It works perfectly in development. Then your launch post lands, five hundred people open the page in the same few seconds, and Vercel boots five hundred copies of your function, one per request, all at once.
Every one of those functions has to reach Postgres without knocking it over. You already have a schema, a Neon Postgres, and a DATABASE_URL from the last three lessons. What’s missing is the connection itself, and that’s two decisions, not one. Get either wrong and you ship a bug that hides in development and breaks under load:
- Pooled URL for app traffic, unpooled URL for migrations and long scripts.
- HTTP driver for one-shot reads, WebSocket driver for transactions.
Four rules, one cause: how serverless works. We’ll start by watching the database fall over.
Why a connection per request exhausts Postgres
Section titled “Why a connection per request exhausts Postgres”A connection is not free
Section titled “A connection is not free”When your function opens a connection to Postgres, it first pays the handshake : the round-trips that run before a single query does.
Then, for each connection, Postgres forks a backend , a full operating-system process holding its own memory. Not a thread, not a table entry: a program running on the server.
Because backends are real processes, their number is capped. Postgres enforces a ceiling called max_connections , past which it refuses to open more. On a typical managed plan it sits around a hundred. The exact number shifts with the plan and version; what matters is that the ceiling is low enough for five hundred functions to hit without trying.
The traditional fix, and why serverless can’t use it
Section titled “The traditional fix, and why serverless can’t use it”On a normal Node server, the standard answer to this is a connection pool . The server opens a small, fixed set of connections once at boot, say ten or twenty, and keeps them alive. Every request borrows one, runs its query, and hands it back, so ten thousand requests an hour can flow through the same ten connections. In the Node world, node-postgres and its Pool are what you’d reach for.
The pool pays for opening connections once, at boot, then spreads that cost across every request for the rest of the server’s life. The bargain only works because the server has a life: a long-lived process whose memory holds the pool.
A serverless function has no such life. It starts when a request arrives, runs it, and shuts down. There is no “once, at boot” to spread the cost against, because by the next request the function, and any pool it opened, is gone. The in-process pool assumes a long-lived server, and a serverless function is its opposite.
The spike
Section titled “The spike”On launch day, each function, unable to reuse anything, opens its own connection. Three functions, three connections; fifty functions, fifty connections, still fine. But five hundred functions is five hundred connections reaching for backends at the same instant, against a ceiling around a hundred.
Past the ceiling, Postgres refuses. New connections come back with FATAL: sorry, too many connections for role, the function throws, and the user gets a 500 instead of their invoices, at the moment your launch is sending the most traffic you’ve ever had.
Nothing warned you. On your laptop you are one developer making one request, never close to the ceiling. The bug appears only when many functions run at once, the one condition you can’t reproduce alone at your desk.
Scrub through the sequence below. Each step shows your functions on the left and Postgres on the right, with a live count of connections against the cap. Watch it climb, hit the ceiling, and turn red; the last frame previews the fix.
The ceiling. Past max_connections, Postgres refuses new connections with FATAL: too many connections. Those functions throw; their users get a 500.
Notice where that answer lives. Years ago the fix was your job: tune your app’s pool, set its size, manage its lifecycle. In 2026 it has moved out of your application code. The database provider runs the pooler in front of Postgres, and your app just talks to it. So the first half of this lesson isn’t something you build, it’s something you select, by choosing the right connection string.
The pooled connection string
Section titled “The pooled connection string”The first fix is one decision: which URL your app connects to. This section is only about that string. How the call site sends a query, over HTTP or WebSocket, is the next decision, and conflating the two is the most common way this topic confuses people.
What Neon runs in front of Postgres
Section titled “What Neon runs in front of Postgres”Neon places a PgBouncer in front of the database, in transaction mode .
PgBouncer holds a small, fixed set of real connections to Postgres, the expensive backends from the last section. In front of them it accepts an enormous number of cheap client connections from your booting functions. Transaction mode is the bridge: PgBouncer lends a real backend to a client for the duration of one transaction, then takes it back. The instant a transaction ends, that backend returns to the pool and goes to the next client waiting. This is multiplexing , many client connections taking turns on a few server connections.
Run the launch-day numbers through that rule. Five hundred functions connect, and PgBouncer happily holds five hundred cheap client connections. But each one needs a real backend only for the few milliseconds its transaction runs, so a few dozen backends, rapidly handed around, serve all five hundred. The connection count to Postgres stays flat and low while the function count spikes.
How you turn it on
Section titled “How you turn it on”Neon gives you two endpoints for the same database, differing by a single token in the hostname. The pooled endpoint carries a -pooler segment; the direct one doesn’t:
postgresql://app:••••@ep-cool-haze-12345678-pooler.us-east-2.aws.neon.tech/neondb?sslmode=requireThis is DATABASE_URL in production. Every Server Component, Server Action, and route handler connects through it, and the -pooler host routes them through PgBouncer.
postgresql://app:••••@ep-cool-haze-12345678.us-east-2.aws.neon.tech/neondb?sslmode=requireSame database, same credentials; the host skips the pooler and reaches Postgres directly. Reserved for the few jobs that need a stable connection, below.
Same database, username, password, and database name. The only thing that changes is the host’s routing: with -pooler you reach PgBouncer, without it you reach Postgres directly. Neon’s console hands you both behind a toggle.
The default is simple: the pooled URL is for all serverless app traffic, and that’s what DATABASE_URL points at in production. The direct URL is the exception.
What transaction-mode pooling takes away
Section titled “What transaction-mode pooling takes away”This isn’t free. Transaction mode hands your next transaction a different backend than your last one, so anything that assumes “I’m still on the same connection” breaks:
- A prepared statement pinned to a session. You prepare it on one backend; your next transaction lands on another that’s never heard of it. (Modern drivers, including the ones Drizzle uses, handle this, so you’ll rarely trip it.)
- Session-level state:
SETfor the session, session-scopedLISTEN/NOTIFY, an advisory lock held across statements. All of it dies the moment the transaction ends and the backend returns to the pool. - A temporary table created outside a transaction. It lived on a backend you no longer have.
The habit that follows: keep your work inside transactions, don’t lean on session state, and you’ll never notice the pooler is there. Everyday reads and writes already fit that rule, which is why pooling is invisible in normal use.
Two operations genuinely need a stable session. Migrations run a long stream of schema-changing statements that must all land on the same connection, and long-running maintenance scripts may hold a session open for minutes. Transaction-mode pooling would cut either off at a transaction boundary, which is exactly why the unpooled, direct URL exists; it returns later in the chapter.
Two ways to send a query: HTTP and WebSocket
Section titled “Two ways to send a query: HTTP and WebSocket”The second decision is independent of the first. The URL choice picked which database endpoint your app connects to; this one picks how the call site talks over that connection. Both shapes below typically use the pooled URL, so the two choices really are separate.
To send queries from a serverless runtime you reach for @neondatabase/serverless , which hands you two shapes for talking to the database. The difference between them is what this section is about.
HTTP: one query, one fetch
Section titled “HTTP: one query, one fetch”The first shape is neon(connectionString), which talks to Postgres over HTTP. Each query is a single fetch: one POST to Neon’s SQL-over-HTTP endpoint, carrying your query and returning your rows.
This fits serverless because there is no persistent connection to hold: nothing to leak when your function shuts down, nothing to exhaust under a spike. The held connection that failed at the top of the lesson isn’t part of this picture at all. For a single one-shot query, HTTP is also the lowest-latency path Neon offers.
The limit follows from the same fact: with no persistent connection, you can’t hold a transaction open across round-trips. The HTTP driver does support a non-interactive batch, transaction([q1, q2, q3]), an array of queries sent in one round-trip and run together. What it can’t do is the interactive shape: begin, read a result, branch on it, then write, all on one held connection. For that you need the second shape.
WebSocket: a held connection for real transactions
Section titled “WebSocket: a held connection for real transactions”The second shape is Pool (or Client), which talks over a WebSocket held open across many queries. It’s API-compatible with node-postgres’s Pool, and it gives you what HTTP lacks: a connection that stays open long enough for an interactive transaction . Begin, read, decide, write, all on one connection, all-or-nothing.
Reach for it when a single request has to read the current state, decide based on what it read, and write the result atomically: deduct credits only if the balance covers them, or insert a row and update a counter together or not at all. Each needs one connection held across several steps.
const sql = neon(env.DATABASE_URL);const invoices = await sql`select * from invoices where org_id = ${orgId}`;One query, one fetch, nothing held open. The default for reads in a Server Component; a later chapter wraps it with drizzle-orm/neon-http.
const pool = new Pool({ connectionString: env.DATABASE_URL });const dbWs = drizzle(pool);await dbWs.transaction(async (tx) => { const [account] = await tx.select().from(accounts).where(eq(accounts.id, id)); await tx.update(accounts).set({ credits: account.credits - cost }).where(eq(accounts.id, id)); await tx.insert(charges).values({ accountId: id, amount: cost });});A persistent connection, for when one request must read, decide, then write atomically. A later chapter wraps this with drizzle-orm/neon-serverless.
Later in this unit, drizzle-orm/neon-http wraps the HTTP driver and drizzle-orm/neon-serverless wraps the WebSocket Pool: two wrappers, one per driver shape.
Which one a call site reaches for
Section titled “Which one a call site reaches for”The rule maps onto things you already recognize:
- A Server Component reading data to render reaches for HTTP. It fetches once and renders; there’s no transaction, and latency on that single read is what the user feels. This is the course’s default for data fetches.
- A Server Action or route handler that writes reaches for WebSocket, but only when the write needs a real transaction: read-modify-write, or several statements that must all land or all fail. (Server Actions come later in the course.)
The short version: one read → HTTP; a transaction → WebSocket.
Now fold both decisions, URL and driver, into one walk. The first question isn’t about reads or transactions: it’s what kind of call site this is. If the answer is a migration, the serverless story doesn’t apply and you take the direct URL.
The default for Server Component reads. One fetch per query, no connection held open, lowest latency for a single read. Connects through the pooled DATABASE_URL.
A persistent connection for interactive transactions, for when one request must read a result and branch on it before writing. Still rides the pooled DATABASE_URL; the held connection is short-lived per request.
Migrations and long scripts need a single stable session across many statements, so they skip the pooler and use the direct host. How to run migrations comes later in this unit; here you just need to know they take the other URL.
Three clients, one job each
Section titled “Three clients, one job each”By course convention, your db/index.ts exports three database clients.
db is the pooled HTTP client, imported almost everywhere a Server Component reads data.
dbTx is the pooled WebSocket client, over the same pooled URL, for when a Server Action needs an interactive transaction.
dbUnpooled rides the direct, unpooled URL, reserved for migrations and long-running scripts.
Two environment variables back them in production: DATABASE_URL for the two pooled clients and DATABASE_URL_UNPOOLED for the direct one.
Three, not one, because each rides the axis it does:
dbrides the pooler over HTTP because app traffic is many short bursts, and those one-shot reads must never exhaust Postgres.dbTxrides the same pooler over a WebSocket, because a Server Action that reads a balance, decides, then writes needs one connection held across several steps. The WebSocket is held only for the transaction’s few milliseconds, which transaction-mode pooling handles fine.dbUnpooledskips the pooler because a migration is one long session that has to stay on one connection from start to finish.
That last point carries a real hazard.
Run a migration over the pooled URL and PgBouncer can reclaim the backend at a transaction boundary, cutting the connection mid-migration and leaving a half-applied schema change.
Avoiding exactly that is why dbUnpooled exists.
Read the sketch below as a shape: three exports, and why each is the way it is.
import { drizzle as drizzleHttp } from 'drizzle-orm/neon-http';import { drizzle as drizzleWs } from 'drizzle-orm/neon-serverless';import { Pool } from '@neondatabase/serverless';import { env } from '@/env';
export const db = drizzleHttp(env.DATABASE_URL);
export const dbTx = drizzleWs(new Pool({ connectionString: env.DATABASE_URL }));
export const dbUnpooled = drizzleWs(new Pool({ connectionString: env.DATABASE_URL_UNPOOLED }));Two drivers, one per call-site shape: HTTP for one-shot reads, WebSocket for transactions. The WebSocket driver backs two of the three clients, one pooled and one not.
import { drizzle as drizzleHttp } from 'drizzle-orm/neon-http';import { drizzle as drizzleWs } from 'drizzle-orm/neon-serverless';import { Pool } from '@neondatabase/serverless';import { env } from '@/env';
export const db = drizzleHttp(env.DATABASE_URL);
export const dbTx = drizzleWs(new Pool({ connectionString: env.DATABASE_URL }));
export const dbUnpooled = drizzleWs(new Pool({ connectionString: env.DATABASE_URL_UNPOOLED }));db is the default. It rides the pooled DATABASE_URL over HTTP, the right shape for one-shot Server Component reads.
import { drizzle as drizzleHttp } from 'drizzle-orm/neon-http';import { drizzle as drizzleWs } from 'drizzle-orm/neon-serverless';import { Pool } from '@neondatabase/serverless';import { env } from '@/env';
export const db = drizzleHttp(env.DATABASE_URL);
export const dbTx = drizzleWs(new Pool({ connectionString: env.DATABASE_URL }));
export const dbUnpooled = drizzleWs(new Pool({ connectionString: env.DATABASE_URL_UNPOOLED }));dbTx is the transactional client. Same pooled DATABASE_URL, but over a WebSocket, so a Server Action can hold one connection across a read-decide-write.
import { drizzle as drizzleHttp } from 'drizzle-orm/neon-http';import { drizzle as drizzleWs } from 'drizzle-orm/neon-serverless';import { Pool } from '@neondatabase/serverless';import { env } from '@/env';
export const db = drizzleHttp(env.DATABASE_URL);
export const dbTx = drizzleWs(new Pool({ connectionString: env.DATABASE_URL }));
export const dbUnpooled = drizzleWs(new Pool({ connectionString: env.DATABASE_URL_UNPOOLED }));dbUnpooled is the escape hatch. A WebSocket over the direct, unpooled URL, for migrations and long scripts that need one stable session.
One DATABASE_URL in your head, two endpoints underneath.
Your app reasons about a single logical database; the two connection strings, one through the pooler and one around it, are an operational detail the client exports hide.
Most of the time you import db, query, and move on.
Sort each operation into the client and driver it should use.
Sort each operation into the database client and driver it should use. Drag each item into the bucket it belongs to, then press Check.
drizzle-kit migrate on deployThe two axes are independent, and that is where people slip.