Why Your Next.js Route Handlers Exhaust Postgres Connections (and How to Fix It)
Why Your Next.js Route Handlers Exhaust Postgres Connections (and How to Fix It) Everything worked in local development. When I built out my Next.js API routes, I initialized the PostgreSQL connection directly inside t
Why Your Next.js Route Handlers Exhaust Postgres Connections (and How to Fix It)
Everything worked in local development. When I built out my Next.js API routes, I initialized the PostgreSQL connection directly inside the route handler, tested it against my local database, and saw fast, clean responses. There was no indication that anything was wrong.
Then I deployed the application to a serverless environment. Under relatively low traffic, the logs started lighting up with intermittent database connection errors. The queries were simple, the data volume was modest, and the traffic was far from a spike. Yet the database was repeatedly refusing connections.
Here is why instantiating connections inside your Route Handlers breaks down in serverless environments, and how to restructure your database client to handle ephemeral execution.
The Localhost Trap: Why In-Handler Connections Pass in Dev
When running Next.js locally via next dev, you are operating inside a single long-lived Node.js runtime. When you write something like this inside an API route:
// app/api/data/route.ts (Anti-pattern)
import { NextResponse } from 'next/server';
import { Pool } from 'pg';
export async function GET() {
// Initializing the pool inside the handler scope
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const client = await pool.connect();
try {
const result = await client.query('SELECT NOW()');
return NextResponse.json(result.rows);
} finally {
client.release();
// Even if you call pool.end() here, each call incurs handshake latency
}
}
Locally, your requests arrive sequentially from a single browser session. Even if you spin up connections inside the request handler, the low volume rarely reaches the connection cap of a local Postgres instance (which typically defaults to 100 connections).
Because the local runtime masks connection pressure, the code looks production-ready. In reality, it leaks connections or churns through TLS and TCP handshakes on every single invocation.
Tracing the Failure: Serverless Execution Contexts
Serverless platforms (such as Vercel or AWS Lambda) do not run a single persistent Node.js server. Instead, they spin up multiple isolated container instances based on incoming request spikes.
When an API route is invoked:
- Cold Start: The platform launches an execution container, boots the Node.js runtime, and runs your handler code.
- Execution: Your handler opens a pool or client, binds to a Postgres socket, runs the query, and returns the response.
- Pause/Freeze: After the handler returns, the runtime environment may be immediately frozen. If the connection pool has active background sockets or idle connections, they remain open on the database server until Postgres times them out.
- Concurrent Requests: If multiple requests arrive concurrently, the serverless platform spins up sibling containers. Each container executes the route handler and initializes its own brand-new set of connections.
Because PostgreSQL allocates a separate OS process for every incoming client connection, connections are expensive. If 20 concurrent serverless containers spin up, and each handler instantiates a pool or fails to clean up active connections, you can burn through your database connection budget almost instantlyβeven with small traffic numbers.
Fixing the Client: Implementing a Shared Connection Singleton
To prevent handlers from creating new connection pools on every call, move the database initialization out of the route handler's function body and into module scope.
When a serverless container stays "warm" to serve consecutive requests, module-level variables are preserved across invocations. To handle local development hot-reloading (which can repeatedly evaluate modules and spawn stray pools), attach the instance to globalThis.
// lib/db.ts
import { Pool } from 'pg';
const globalForDb = globalThis as unknown as {
connPool: Pool | undefined;
};
export const pool =
globalForDb.connPool ??
new Pool({
connectionString: process.env.DATABASE_URL,
// Restrict the maximum connections per serverless container
max: 1,
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 5000,
});
if (process.env.NODE_ENV !== 'production') {
globalForDb.connPool = pool;
}
Now, consume the singleton in your Route Handler:
// app/api/data/route.ts
import { NextResponse } from 'next/server';
import { pool } from '@/lib/db';
export async function GET() {
const client = await pool.connect();
try {
const result = await client.query('SELECT NOW()');
return NextResponse.json(result.rows);
} finally {
client.release();
}
}
With this pattern, warm serverless containers reuse existing database sockets instead of performing a fresh handshake on every single request.
Setting Connection Pool Limits for Serverless Concurrency
In a traditional Express or Fastify server, setting a pool size of 10 or 20 connections per server instance makes sense because you only run a few long-lived instances.
In a serverless environment, that math inverts. If you configure max: 10, and your platform scales to 15 concurrent container instances, your app will attempt to open up to 150 connections to PostgreSQL. If your database tier has a limit of 100 connections, requests will start failing immediately with connection refused or timeout errors.
When running Postgres drivers directly inside serverless functions:
-
Keep the pool size small: Set
max: 1ormax: 2per function instance. Because a single serverless Node.js instance usually handles requests sequentially or with very low intra-container concurrency, a single connection is usually sufficient. -
Lower the idle timeout: Set
idleTimeoutMillislow (such as 10 to 15 seconds) so that Postgres closes connections when a container goes idle rather than holding them open indefinitely.
Scaling Past Node Memory: Introducing PgBouncer
Limiting each container to one connection helps, but it does not solve high concurrency. If 200 users hit your endpoint at the exact same moment, the serverless provider will spin up 200 instances, immediately demanding 200 distinct Postgres connections.
Traditional relational databases are not designed to handle thousands of short-lived connections. This is where external connection poolersβmost commonly PgBouncerβbecome necessary.
[ Next.js Serverless Container 1 ] --\
[ Next.js Serverless Container 2 ] ---> [ PgBouncer (Pooler) ] ---> [ PostgreSQL ]
[ Next.js Serverless Container N ] --/
PgBouncer sits between your serverless functions and PostgreSQL. It maintains a small, fixed pool of actual connections to Postgres while accepting hundreds or thousands of incoming connections from ephemeral clients.
When configuring a pooler for serverless functions, ensure you use Transaction Pooling: connections are returned to the pool the millisecond a query or transaction finishes, allowing a tiny pool of database connections to serve a massive fleet of serverless handlers.
(Note: If your database provider offers a managed connection pooling endpoint, verify whether it uses transaction pooling or session pooling before pointing your DATABASE_URL to it).
Production Checklist for Serverless Relational Connections
-
Check handler scopes: Ensure no database clients (
ClientorPool) are instantiated inside the body ofGET,POST, or other route methods. -
Use global singletons: Cache the database client across warm serverless invocations and dev hot-reloads using module scope or
globalThis. -
Cap client pool size: Lower
maxconnection settings to 1 or 2 per container to avoid accidental connection multiplication during sudden bursts. - Adopt external pooling: Use an intermediate pooler like PgBouncer or a hosted connection pooler to shield Postgres from container scaling spikes.
- Tune timeouts: Ensure connection timeouts fail fast rather than hanging the serverless function until it hits the runtime execution limit.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.