Neon on Vercel: The Connection Pooling Maze
Edge can't do TCP, serverless exhausts pools—here's when to use pooler endpoint vs @neondatabase/serverless vs raw WebSocket.
Connecting Neon to Vercel sounds simple until you realize there are three different ways to do it, each with different runtime constraints. Edge can't do TCP. Serverless exhausts connection pools. And the pooler endpoint isn't the same as the serverless driver. Here's the maze—and how to navigate it.
The Three Connection Methods
// 1. Direct connection (TCP)
// Works: Node.js serverless functions
// Fails: Edge Runtime (no TCP sockets)
postgresql://user:pass@ep-xyz-123456.us-east-2.aws.neon.tech/db
// 2. Pooler endpoint (TCP via PgBouncer)
// Works: Node.js serverless, high concurrency
// Fails: Edge Runtime (still TCP)
postgresql://user:pass@ep-xyz-123456-pooler.us-east-2.aws.neon.tech/db
// 3. @neondatabase/serverless driver (HTTP/WebSocket)
// Works: Edge Runtime, serverless, everywhere
// Tradeoff: Slightly higher latency for single queries
import { neon } from '@neondatabase/serverless';Edge Runtime: HTTP or WebSocket Only
Vercel Edge Runtime runs on Cloudflare Workers infrastructure—no TCP sockets allowed.
Your only option is the @neondatabase/serverless driver:
// app/api/posts/route.ts (Edge Runtime)
import { neon } from '@neondatabase/serverless';
export const runtime = 'edge';
export async function GET() {
// HTTP mode - fastest for single queries
const sql = neon(process.env.DATABASE_URL!);
const posts = await sql`SELECT * FROM posts LIMIT 10`;
return Response.json(posts);
}
The neon() function uses HTTP under the hood—no persistent connection,
no pooling needed. Each query is a fetch request to Neon's HTTP API.
When You Need WebSocket (Pool/Client)
HTTP mode doesn't support sessions or interactive transactions. For those, use WebSocket:
// WebSocket mode for transactions
import { Pool } from '@neondatabase/serverless';
export const runtime = 'edge';
export async function POST(req: Request) {
// Pool creates WebSocket connections
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
// Transaction with multiple statements
const client = await pool.connect();
await client.query('BEGIN');
await client.query('INSERT INTO posts ...');
await client.query('UPDATE stats ...');
await client.query('COMMIT');
client.release();
return Response.json({ success: true });
} finally {
await pool.end();
}
}Critical: In Edge/Serverless, create the Pool inside the request handler, not at module scope. Module-level pools persist across requests and leak connections.
Serverless Functions: The Connection Exhaustion Problem
Node.js serverless functions can use TCP, but each function instance opens its own
connection. Under load, you hit Neon's max_connections limit fast:
// ❌ BAD: Direct connection in serverless
import { Client } from 'pg';
export async function GET() {
const client = new Client(process.env.DATABASE_URL);
await client.connect(); // New connection per invocation
const result = await client.query('SELECT ...');
await client.end();
return Response.json(result.rows);
}
// 50 concurrent requests = 50 connections
// Neon 0.25 CU limit: 97 connections (104 - 7 reserved)
// You WILL hit "too many connections"Solution 1: Use the Pooler Endpoint
Neon's PgBouncer pooler multiplexes connections:
// Add -pooler to your endpoint
// Before: ep-xyz-123456.us-east-2.aws.neon.tech
// After: ep-xyz-123456-pooler.us-east-2.aws.neon.tech
// Can handle 10,000 client connections via ~377 actual Postgres connections (1 CU)
// Transaction mode: connection returned after each transaction
The pooler uses transaction mode—connections return to the pool after each transaction.
This means SET statements don't persist across queries:
// ⚠️ BROKEN with pooler in transaction mode
await client.query("SET search_path TO myschema");
await client.query("SELECT * FROM mytable"); // search_path reset!
// ✓ FIX: Specify schema explicitly
await client.query("SELECT * FROM myschema.mytable");
// ✓ FIX: Set at role level (persists)
// ALTER ROLE myuser SET search_path TO myschema, public;Solution 2: Serverless Driver (HTTP Mode)
Even in Node.js serverless, the HTTP driver often makes more sense:
// pages/api/posts.ts (Node.js Serverless)
import { neon } from '@neondatabase/serverless';
export default async function handler(req, res) {
const sql = neon(process.env.DATABASE_URL!);
// HTTP: no connection to manage, no pool exhaustion
const posts = await sql`SELECT * FROM posts WHERE id = ${req.query.id}`;
res.json(posts);
}
// 1000 concurrent requests = 1000 HTTP requests to Neon
// Neon handles connection pooling internallyHTTP mode has ~5-10ms overhead per query vs persistent connection, but eliminates connection management entirely. For most CRUD operations, this tradeoff is worth it.
The Decision Matrix
// Edge Runtime?
// → @neondatabase/serverless (HTTP or WebSocket)
// → No choice, TCP not available
// Serverless Node.js, simple queries?
// → @neondatabase/serverless HTTP mode
// → Simplest, no connection management
// Serverless Node.js, transactions needed?
// → @neondatabase/serverless WebSocket mode
// → Or pooler endpoint + pg library
// Serverless Node.js, existing pg codebase?
// → Pooler endpoint (just change connection string)
// → Watch for SET statement issues
// Long-running server (not serverless)?
// → Direct connection with proper pool
// → Standard pg.Pool with connection limitsDrizzle ORM: Which Adapter?
Drizzle has two Neon adapters—use the right one:
// Edge Runtime or HTTP-preferred
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);
// WebSocket/Pool mode (transactions)
import { Pool } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-serverless';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);Common Pitfalls
// ❌ Module-level pool in serverless
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function GET() {
// Pool persists, connections leak between invocations
}
// ✓ Pool inside handler
export async function GET() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try { /* ... */ }
finally { await pool.end(); }
}
// ❌ Forgetting ws polyfill in Node.js
import { Pool, neonConfig } from '@neondatabase/serverless';
// Node.js <21 needs WebSocket polyfill
import { WebSocket } from 'ws';
neonConfig.webSocketConstructor = WebSocket;
// ❌ Using prepared statements with pooler
// PgBouncer transaction mode breaks PREPARE/DEALLOCATEPerformance Reality
HTTP mode adds latency, but less than you'd think:
- HTTP query: ~15-30ms (includes TLS handshake, HTTP overhead)
- WebSocket query: ~5-15ms (persistent connection)
- Direct TCP: ~3-10ms (persistent, no proxy)
For most web apps, the 10-20ms difference is noise compared to rendering time. Connection pool exhaustion is a much bigger production risk than query latency. Default to HTTP unless you have measured evidence that you need persistent connections.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement