A week after I launched klawsfx.com, production started throwing P1001: Can't reach database server errors. Not on every request — intermittently, usually under light traffic, which made it harder to diagnose. The Neon dashboard showed the database was running fine. The Vercel function logs showed the error happening during prisma.$connect().
The cause is a specific combination of Vercel's serverless runtime, Neon's serverless Postgres, and how Prisma manages connections. Here's what's happening and how I fixed it.
Why this combination is dangerous
Vercel deploys Next.js as serverless functions. Every request can land on a different function instance. Each instance boots a new Node.js process, and Prisma's default behaviour is to open a connection pool when PrismaClient is instantiated.
Neon is serverless Postgres. Each Neon database branch has a connection limit. The free tier caps at 100 connections total; paid tiers are higher but still finite.
The problem: if 20 Vercel function instances boot simultaneously (a moderate traffic spike), and each opens a pool of 10 connections by default, that's 200 connections — already over the free tier limit, and causing queuing on paid tiers. The connections don't get released quickly because serverless functions stay warm for a few minutes after handling a request, holding their pools open.
The wrong fix
The first thing most developers try is setting connection_limit=1 in the database URL:
postgresql://user:pass@host/db?connection_limit=1
This works — it limits each instance to one connection. But it also serialises all queries within a single function instance, which increases response time noticeably for pages that run multiple Prisma queries in parallel.
The right fix: PgBouncer via Neon's connection pooler
Neon provides a built-in connection pooler (PgBouncer in transaction mode) on a separate hostname. You use the pooled connection string for your application and the direct connection string only for migrations.
In your Neon dashboard, under your branch settings, you'll find two connection strings:
- Direct:
postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/dbname - Pooled:
postgresql://user:pass@ep-xxx-pooler.us-east-2.aws.neon.tech/dbname?pgbouncer=true
The pooled string routes through PgBouncer. PgBouncer multiplexes many short-lived client connections onto a small set of long-lived server connections. Your 20 Vercel instances each connect to PgBouncer; PgBouncer manages the actual Postgres connections.
Set up two environment variables:
DATABASE_URL=postgresql://user:pass@ep-xxx-pooler.us-east-2.aws.neon.tech/dbname?pgbouncer=true&connection_limit=1
DIRECT_URL=postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/dbname
Then update schema.prisma to use both:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
url is used for all queries. directUrl is used only by prisma migrate — migrations require a direct connection because they run DDL statements that PgBouncer's transaction mode doesn't support.
Prisma Client singleton in Next.js
Even with PgBouncer, you want to avoid creating a new PrismaClient on every request. In development, Next.js hot-reloading destroys and recreates modules, which creates multiple client instances and exhausts the development connection limit fast.
The standard fix is a singleton module:
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
globalThis persists across hot-reloads in development. In production, each serverless instance is a separate process, so globalThis doesn't share across instances — but that's fine, because PgBouncer is handling the multiplexing.
Vercel environment variables for preview branches
One more trap: Vercel preview deployments and production deployments should use the same pooled URL, but if you also have a preview Neon branch for staging, make sure the DIRECT_URL on that branch points to the preview Neon endpoint, not production.
I set Vercel environment variables to apply to "production only" for the prod Neon credentials and "preview" for the staging branch credentials. Mixing them up causes migrations on the preview branch to touch the production database.
After the fix
Connection errors: zero since the change. Average query time dropped slightly because PgBouncer is co-located with the Neon compute and has lower round-trip latency than function instances initiating direct connections from Vercel's edge.
The total connection count visible in Neon's metrics went from spiking above 80 to staying under 10, regardless of traffic.
