Skip to content
Chapter 36Lesson 4

The serverless driver and the pooled URL

How serverless functions reach Neon Postgres without exhausting it, choosing 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, and five hundred people open the page in the same few seconds. Vercel responds the way it’s supposed to: it boots five hundred copies of your function, one per request, all at once.

Every one of those functions needs to reach Postgres. The question this lesson answers is how each one opens a connection without knocking the database over. You already have the pieces from the last three lessons: a schema, a Neon Postgres, and a DATABASE_URL that points at it. The one piece you haven’t built yet is the connection itself, the actual mechanism a function uses to reach the database and run a query. That turns out to be two separate decisions rather than one, and getting either wrong produces a bug that stays invisible in development and breaks badly under load.

Here are the two reflexes you’ll walk away with. Keep them in mind as we go, because the rest of the lesson is the reasoning behind them:

  • Pooled URL for app traffic, unpooled URL for migrations and long scripts.
  • HTTP driver for one-shot reads, WebSocket driver for transactions.

They sound like four unrelated rules, but they all follow from one fact about how serverless works. To make that fact concrete, we’ll start by watching the database fall over.

Why a fresh connection per request breaks Postgres

Section titled “Why a fresh connection per request breaks Postgres”

Before we can talk about fixes, you need to see the failure clearly. Let’s build the simplest possible picture of what a connection costs, and then break it.

When your function opens a connection to Postgres, a surprising amount happens. There’s a TCP handshake, then a TLS handshake to encrypt the channel, then an authentication round-trip to prove who you are. Call the whole thing the handshake : the back-and-forth paid before a single query runs. That’s the part you might guess at.

The part that matters more is what happens after the handshake. For each connection, Postgres forks a real operating-system process to serve it: a backend , a whole process dedicated to your one connection, holding its own slice of memory. It isn’t a lightweight thread or an entry in a table; it’s a full process. So a connection in Postgres is expensive in a literal sense: each one is a program running on the server.

Because backends are real processes, you can’t have an unlimited number of them. Postgres enforces a ceiling called max_connections , past which it simply refuses to open more. On a typical managed plan that ceiling sits somewhere around a hundred. The exact number isn’t worth memorizing, since it shifts with the plan and the version. What matters is that the ceiling exists, and that it’s low enough for five hundred functions to hit it without trying.

The traditional fix, and why serverless can’t use it

Section titled “The traditional fix, and why serverless can’t use it”

If you’ve seen a database connected from a normal Node server, you’ve already seen the standard answer to this, and on that kind of server it works beautifully.

The trick is a connection pool . Instead of opening a connection per request, the server opens a small, fixed set once when it boots, say ten or twenty, and keeps them alive. Every incoming request borrows a connection from that pool, runs its query, and hands it back. Ten thousand requests an hour might flow through the same ten connections. The library that does this in the Node world is node-postgres , and its Pool is the workhorse you’d reach for.

It’s worth being precise about why that works, because the reason drives everything that follows. The pool pays the cost of opening connections once, at boot, and then spreads it across every request for the rest of the server’s life. That bargain only makes sense because the server has a life: it’s a long-lived process that stays running for days. The pool lives in the server’s memory, and the server is always there.

Now hold that next to a serverless function . A serverless function does not stay running. It starts when a request arrives, it runs that one request, and then it shuts down. There is no “at boot, once” for it to spread the cost against, because by the time the next request shows up, this function, and any pool it might have opened, may no longer exist.

This is the point everything else rests on: the in-process pool assumes a long-lived server, and a serverless function is the opposite of a long-lived server. Every fix in this lesson is an answer to that one broken assumption.

So what actually happens on launch day? Each function, unable to reuse anything, opens its own fresh connection. Three functions, three connections, which is fine. Fifty functions, fifty connections, still fine. But five hundred functions is five hundred connections, all reaching for backends at the same instant, and the ceiling is around a hundred.

Past the ceiling, Postgres starts refusing. New connections come back with FATAL: sorry, too many connections for role, the refused function throws, and the user gets a 500 instead of their invoices, at the exact moment your launch is sending you the most traffic you’ve ever had.

The hard part is that nothing warned you. On your laptop you are one developer making one request, opening a handful of connections, never close to the ceiling. The bug only appears when many functions run at once, which is exactly the condition you can’t reproduce alone at your desk. It looks fine in development and breaks under load, and that combination is what makes it the kind of failure experienced engineers learn to anticipate.

Scrub through the sequence below to watch it happen. Each step shows your functions on the left and Postgres on the right, with a live count of how many connections Postgres is holding against its cap. Watch the gauge climb, hit the ceiling, and turn red. The last frame previews the fix.

serverless functions
fn
fn
fn
Postgres
connections 3 / 100
well under the cap
Calm. Three requests, three functions, three connections. Postgres is nowhere near its ceiling.
serverless functions
fn
fn
fn
fn
fn
fn
fn
fn
fn
fn
+ hundreds more
Postgres
connections 90 / 100
racing the wall
The spike. The launch post lands; hundreds of functions boot at once, each opening its own connection. The count races toward the ceiling.
serverless functions
fn
fn
fn
fn
fn
fn
fn
fn
fn
fn
+ hundreds more
Postgres FATAL: too many connections
connections 100 / 100
at the wall

The ceiling. Past max_connections, Postgres refuses new connections with FATAL: too many connections. Those functions throw; their users get a 500.

serverless functions
fn
fn
fn
fn
fn
fn
fn
fn
fn
fn
+ hundreds more
Pooler PgBouncer
Postgres
connections 3 / 100
flat, no matter the spike
The fix, previewed. Put a pooler in front. The same hundreds of functions connect to it, but the pooler keeps only a small, fixed set of connections to Postgres, so the count stays flat and low no matter how big the spike. The next section explains how.

That last frame is the answer, and it’s worth noticing where the answer lives. Years ago, the fix would have been your job: you’d tune your app’s connection pool, set its size, manage its lifecycle. In 2026 the fix has moved out of your application code entirely. 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.

Here’s the first fix, and it turns on one decision: which URL your app connects to. This section is only about the connection string. How the call site actually sends a query, over HTTP or WebSocket, is a separate decision we get to next, and merging the two is the most common way this topic confuses people, so it’s worth keeping them apart from the start.

Neon places a PgBouncer in front of the database, running it in transaction mode . Transaction mode is the detail that makes the whole thing work, so let’s unpack it.

PgBouncer maintains a small, fixed set of real connections to Postgres: the backends, the expensive things from the last section. In front of those, it accepts an enormous number of cheap client connections from your booting functions. The bridge between the two is the rule that defines transaction mode: PgBouncer lends a real backend to a client only 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 very 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 to PgBouncer, which is happy to hold five hundred cheap client connections. But each function only needs a real backend for the handful of milliseconds its transaction runs, so a few dozen real backends, rapidly handed around, serve all five hundred. The connection count to Postgres stays flat and low while the function count spikes, exactly the last frame of the diagram you just scrubbed through.

What’s striking is how little you do to get this. Neon gives you two endpoints for the same database, and they differ by a single token in the hostname. The pooled endpoint carries a -pooler segment in its host; the direct one doesn’t:

postgresql://app:••••@ep-cool-haze-12345678-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require

This is DATABASE_URL in production. Every Server Component, Server Action, and route handler connects through this string, and the -pooler host routes them through PgBouncer.

Same database. Same username, same password, same 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. The connection-string box has a toggle for the pooled version, so in practice you copy whichever one the situation calls for.

The default could not be simpler: the pooled URL is for all serverless app traffic. That’s what DATABASE_URL points at in production. The direct URL is the exception, for the handful of cases we’ll name in a moment.

This isn’t free, and it’s worth knowing the price so you recognize the failure if you ever trip it. Transaction mode works by handing your next transaction a different backend than your last one. So anything that quietly assumes “I’m still on the same connection I was on a moment ago” breaks, because you’re probably not. The things that break:

  • A prepared statement pinned to a session. You prepare a statement on one backend; your next transaction lands on a different backend that’s never heard of it. (Modern drivers, including the ones Drizzle uses, handle this for you, so you’ll rarely trip it, but now you’d recognize the symptom.)
  • Session-level state: SET for the session, session-scoped LISTEN/NOTIFY , an advisory lock held across statements. All of it belongs to a session, and the session is gone the moment the transaction ends and the backend goes back to the pool.
  • A temporary table created outside a transaction. Same story: it lived on a backend you no longer have.

All of that turns into one habit: keep your work inside transactions, don’t lean on session state, and you’ll never notice the pooler is there. Your everyday reads and writes already fit that rule, which is why pooling is invisible in normal use.

But two operations genuinely do need a stable, persistent session, and the course cares about both. Migrations run a long stream of schema-changing statements that must all land on the same connection. Long-running maintenance scripts may hold a session open for minutes. Transaction-mode pooling would cut either one off at a transaction boundary, and that is exactly why the unpooled, direct URL from the comparison above exists. Keep that fact in mind, because it comes back two sections from now.

Two ways to send a query: HTTP and WebSocket

Section titled “Two ways to send a query: HTTP and WebSocket”

Now the second decision, which is genuinely independent of the first. The URL choice decided which database endpoint your app connects to. This one decides how the call site talks over that connection. You make both choices, and you make them separately. In fact, both shapes you’re about to see typically use the pooled URL for app traffic, so the two decisions really are orthogonal.

To send queries from a serverless runtime, you reach for @neondatabase/serverless . It’s at version 1.0, generally available and stable. It hands you two different shapes for talking to the database, and the difference between them is what this section is about.

The first shape is neon(connectionString), and it talks to Postgres over HTTP. Each query you run is a single fetch: one POST to Neon’s SQL-over-HTTP endpoint, carrying your query, returning your rows.

Consider why that fits serverless so well. There is no persistent connection to hold, which means there’s nothing to leak when your function shuts down and nothing to exhaust under a spike. The held connection that caused the failure at the top of the lesson simply isn’t part of this picture. For a single one-shot query, an HTTP request is also the lowest-latency path Neon offers.

The catch is the flip side of the same coin: with no persistent connection, you can’t hold a transaction open across multiple 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 in one transaction. What it cannot do is the interactive shape: begin, read a result, branch on what you read, 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), and it talks over a WebSocket , a connection held open across many queries. It’s API-compatible with node-postgres’s Pool, and it gives you back the thing HTTP gave up: a connection that stays open long enough for an interactive transaction . Begin, read, decide, then write, all on the same connection, all-or-nothing.

This is the shape to reach for when a single request has to read the current state, make a decision based on what it read, and write the result atomically. You deduct credits only if the balance covers them; you insert a row and update a counter together or not at all. Both need one connection held across several steps, and that’s what the WebSocket Pool is for.

const sql = neon(env.DATABASE_URL);
const invoices = await sql`select * from invoices where org_id = ${orgId}`;

One query, one fetch, nothing held open. This is the default for reads in a Server Component; the next chapter wraps it with drizzle-orm/neon-http.

Those two import names are worth remembering, because you’ll meet them for real in the next chapter: drizzle-orm/neon-http wraps the HTTP driver, and drizzle-orm/neon-serverless wraps the WebSocket Pool. Don’t worry about the Drizzle setup yet, since that’s the next chapter’s whole job. For now they’re just labels: two wrappers, one per driver shape.

The decision rule is short, and it maps onto things you already know how to 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 are taught later in the course; you don’t need them now. Just know this is where the WebSocket driver earns its keep.)

The short version to carry: one read → HTTP; a transaction → WebSocket.

Now let’s fold both decisions, the URL choice and the driver choice, into a single walk, so you practice the order an experienced engineer asks these questions in. Step through the decision below. Notice that the first question isn’t about reads or transactions at all; it’s “what kind of call site is this?” If the answer is a migration, the whole serverless story doesn’t apply and you take the direct URL instead.

Which connection does this call site take?

All of this comes together in one concrete place. The reasoning from the whole lesson collapses into a single small file you’ll meet in the next chapter. Once you understand why it has the shape it does, that file will read like an obvious consequence rather than a convention to memorize.

The shape is this: by course convention, your db/index.ts exports three database clients. The first is named db, the pooled HTTP client, imported almost everywhere a Server Component reads data. The second is dbTx, the pooled WebSocket client, over the same pooled URL but reached for when a Server Action needs a real interactive transaction. The third is dbUnpooled, over the direct, unpooled URL, reserved for migrations and long-running maintenance and almost nothing else. Two environment variables back all three in production: DATABASE_URL for the two pooled clients and DATABASE_URL_UNPOOLED for the direct one.

Why three and not one? Because every piece of this lesson points at exactly this split:

  • db rides the pooler over HTTP because app traffic is many short bursts, the launch-day spike, and those one-shot reads must never exhaust Postgres. The pooler is what stands between your traffic and FATAL: too many connections.
  • dbTx rides the same pooler but over a WebSocket, because some app requests, like a Server Action that reads a balance, decides, then writes, need one connection held across several steps. It’s still pooled app traffic, just the transactional shape: the WebSocket is held only for the transaction’s few milliseconds, which transaction-mode pooling handles fine.
  • dbUnpooled skips the pooler because a migration is one long session, a stream of schema changes that has to stay on one connection from start to finish. This is the payoff of the unpooled URL we promised two sections ago.

That last point carries a real risk, and it’s worth spelling out the consequence. Run a migration over the pooled URL and you’ve handed a long BEGIN … many ALTER and CREATE statements … COMMIT to a pooler that’s allowed to reclaim the backend at any transaction boundary. PgBouncer can cut the connection out from under your migration partway through, leaving a half-applied schema change, which is one of the hardest states to recover a production database from. Avoiding exactly that is the entire reason dbUnpooled exists. (Running migrations is a later chapter’s topic; here we only justify why a separate unpooled client is in the file.)

Here’s the sketch. Read it as a shape, not a finished file. The next chapter fills in the real Drizzle configuration; what matters right now is that there are three exports and why each one 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 imported, one per call-site shape: HTTP for one-shot reads, WebSocket for transactions. The WebSocket driver backs two of the three clients below, one pooled, one not. This is a preview; the next chapter adds the real Drizzle configuration (schema, casing) that this sketch leaves out.

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, imported almost everywhere. It rides the pooled DATABASE_URL over HTTP, the right shape for one-shot Server Component reads. App traffic is many short bursts; the pooler keeps them from exhausting Postgres.

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. The pooler is fine here: the connection is held only for the transaction’s few milliseconds.

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, reserved for migrations and long scripts that need one stable session. Run those over the pooled URL and the pooler could cut off a migration mid-transaction.

1 / 1

One last way to frame it: one DATABASE_URL in your head, two endpoints underneath. Your app reasons about a single logical database. The fact that there are really two connection strings, one through the pooler and one around it, is an operational detail, and the client exports are the seam that hides it. Most of the time you import db, you query, and you move on.

Before the close, a quick check to make sure it all came together. 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.

Pooled db / HTTP One-shot reads
Pooled db / WebSocket Interactive transactions
Unpooled dbUnpooled Migrations & long sessions
Render an invoice list in a Server Component
Fetch one customer by id for a detail page
A Server Action that deducts credits, then inserts a charge — atomically
drizzle-kit migrate on deploy
A nightly cleanup script that holds a session open for minutes

Let’s come back to the two reflexes from the top of the lesson. By now they aren’t rules you’re taking on trust; they’re conclusions you’ve derived. Here is each one tied back to its root cause, so you leave with the reasoning rather than four loose facts:

  • Pooled URL for app traffic; unpooled URL for migrations and long scripts. Because serverless app traffic is many short connections that must not exhaust Postgres, while a migration is one long session the pooler would sever mid-transaction.
  • HTTP driver for reads; WebSocket driver for transactions. Because a one-shot read needs no held connection at all, while a read-modify-write needs a single connection held across several steps.

And underneath both of them sits one line worth keeping: match the connection to the call site’s lifetime. One logical DATABASE_URL, two endpoints, three clients. A read that lives for a single fetch gets the connection-less HTTP path through the pooler. A transaction that must stay open gets a held WebSocket over that same pooler. A migration that runs for a long time gets the direct line the pooler won’t reclaim. Lifetime decides the rest.

A few of these threads get picked up later, and it’s worth knowing where. The next chapter is where Drizzle actually wires db, dbTx, and dbUnpooled. Server Actions, later in the course, are where the transactional WebSocket client (dbTx) becomes the natural choice. Migrations run on the unpooled URL in a later chapter of this unit. And from here on, every query you write flows through one of these clients.

Let’s check the one thing that trips people up: the boundary between the two axes.

A Server Action on production traffic must read an account balance, confirm it covers a charge, then deduct the credits and insert the charge row — all or nothing. Pick the connection string and the driver it should reach for.

The unpooled direct URL with the WebSocket driver — a transaction needs a held connection, so it has to skip the pooler.
The pooled DATABASE_URL with the WebSocket driver.
The pooled DATABASE_URL with the HTTP driver — every Server Action runs over HTTP.
Either URL paired with the WebSocket driver — once you’re holding a connection, which host you dialed stops mattering.