EdgeCases Logo
Mar 2026
Next.js
Deep
7 min read

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.

neon
vercel
postgres
connection-pooling
edge-runtime
serverless
database

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 internally

HTTP 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 limits

Drizzle 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/DEALLOCATE

Performance 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

Related Insights

Explore related edge cases and patterns

Next.js
Surface
Vercel Blob Storage: When It Makes Sense (and When It Doesn't)
6 min
Next.js
Expert
Vercel Billing Demystified: Edge Requests, Function Duration, and ISR Costs
8 min
React
Expert
React Key Prop: The Reconciliation Deep Dive
8 min

Advertisement