Skip to content
PS

4 July 2026 ·

Drizzle ORM + Neon on Vercel: a serverless setup that doesn't leak connections


The first time I put a Postgres-backed Next.js app on Vercel under real traffic, it fell over with sorry, too many clients already. The database was barely doing any work — the problem was that serverless and traditional Postgres connections are a bad fit, and nobody warns you until production does.

This is the database layer I now ship on every God-Stack project: Drizzle ORM + Neon on Vercel, set up so it doesn't leak connections. Here's the whole thing — why it breaks, which Neon driver to reach for, and the small number of decisions that make it reliable.

Why serverless exhausts Postgres

A classic Postgres server has a hard max_connections limit — often 100 on a small instance. That's fine for one long-lived Node process holding a pool of, say, 10 connections.

Serverless breaks that assumption. Each function invocation can be its own short-lived instance, and if every invocation opens its own database connection, a traffic spike opens hundreds of them at once. You hit max_connections, new requests can't get a connection, and the app throws. The database isn't overloaded — the connection count is.

There are two ways out, and the good setup uses both:

  1. Put a connection pooler in front of Postgres so thousands of clients share a small pool of real connections.
  2. Use a driver that doesn't hold a persistent connection for the request/response path.

Neon gives you the first for free, and its serverless driver gives you the second.

The two Neon drivers — and when to use each

Neon's @neondatabase/serverless package ships two ways to connect, and Drizzle has an adapter for each. Picking the right one is 90% of getting this right.

HTTP driver (drizzle-orm/neon-http) — each query is a stateless HTTPS request. There's no connection to hold open, nothing to leak. This is the default I reach for in Next.js: the overwhelming majority of route handlers and Server Components do a query or two and return.

// db.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql, schema });

WebSocket Pool driver (drizzle-orm/neon-serverless) — a real session over a WebSocket, which you need for interactive transactions: multiple statements that must run on the same connection with app logic in between (db.transaction(async (tx) => { ... })). The HTTP driver can't do those.

// db-pool.ts — only where you need interactive transactions
import { Pool } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";
import * as schema from "./schema";

const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle({ client: pool, schema });

The rule I follow: HTTP by default, Pool only where you genuinely need a transaction. Most apps ship a single db.ts on the HTTP driver and never touch the Pool one.

The pooled connection string is not optional

Neon hands you two connection strings, and the difference is the whole game:

  • Pooled — host looks like ...-pooler.region.aws.neon.tech. Routes through Neon's PgBouncer, so many clients share a handful of real Postgres connections. This is the one your app uses at runtime.
  • Direct — no -pooler. A raw connection to Postgres. You want this for migrations (more on that below).

Set DATABASE_URL to the pooled string. If you point your running app at the direct one, you've thrown away the pooler and you're back to exhausting connections under load.

Define the schema

Nothing exotic — a plain Drizzle schema file. This is also what gives you end-to-end type safety: the inferred types flow straight into your queries.

// schema.ts
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";

export const posts = pgTable("posts", {
  id: serial("id").primaryKey(),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

Migrations — and why they use the direct URL

Drizzle Kit generates versioned SQL from your schema and applies it. The config:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./schema.ts",
  out: "./drizzle",
  dbCredentials: {
    // DIRECT (unpooled) URL — not the -pooler host
    url: process.env.DATABASE_URL_UNPOOLED!,
  },
});
npx drizzle-kit generate   # schema diff -> SQL migration files
npx drizzle-kit migrate    # apply them

Migrations run against the direct connection because PgBouncer in transaction-pooling mode doesn't support the session-level operations some DDL needs. So keep two env vars: DATABASE_URL (pooled, for the app) and DATABASE_URL_UNPOOLED (direct, for drizzle-kit). Neon shows you both in the dashboard.

The part Fluid Compute quietly fixes

There's a second, subtler leak: creating the client inside a request handler. Every request builds a new client, and with the Pool driver that means a new pool every time.

Instantiate the client once at module scope — as in the db.ts above — and import that singleton everywhere. Module-scope code runs once per instance, not once per request.

This is where Vercel's Fluid Compute helps rather than hurts. Fluid reuses a warm function instance across many invocations instead of the old one-request-per-instance model, so a module-scope client (and, for the Pool driver, its pool) is created once and reused across all the requests that instance serves. Fewer cold starts, fewer new connections. The old serverless advice to "open and close a connection per request" is exactly wrong here — you want the opposite.

You still keep the pooled connection string. Fluid reduces how often you create clients; the Neon pooler handles the fan-out when traffic bursts and several instances are warm at once. Belt and braces.

Wiring the env on Vercel

Set both variables in the Vercel project (or pull them locally):

vercel env pull .env.local

.env.local for local dev, the Vercel dashboard (or vercel env add) for preview and production. Neon's Vercel integration can inject these for you and even give each preview deployment its own database branch — but that's a separate post.

The whole decision, on one page

  • Default driver: neon-http. Stateless, nothing to leak.
  • Only for interactive transactions: neon-serverless Pool.
  • App connection string: the pooled (-pooler) URL, in DATABASE_URL.
  • Migrations: the direct URL, via drizzle-kit and a separate env var.
  • Client lifetime: built once at module scope, imported everywhere — never inside a handler.

Do those five things and Postgres connection count stops being something you think about, even when a campaign sends a spike of traffic at 9am. It's not clever — it's just matching the driver and the connection string to how serverless actually runs your code.