← You Don't Need Redis

Chapter 15: The Serverless Connection Crisis

The Waiter of Gold Lapel · Updated Mar 30, 2026 Published Mar 11, 2026 · 10 min

PostgreSQL was designed in an era when a "connection storm" meant twelve application servers restarting simultaneously after a deployment. It was not designed for an era when a viral TikTok can spawn ten thousand Lambda functions in thirty seconds, each one politely requesting its own dedicated database process.

The framework chapters are behind us. You know how to create materialized views, refresh them, and integrate them into every major framework. Part IV addresses what happens when the infrastructure serving those views encounters the scaling patterns of modern deployment architectures. And the first pattern we must address is the most common cause of production outages in serverless PostgreSQL applications: connection exhaustion.

The problem is not that serverless is a poor architecture. The problem is that serverless and PostgreSQL were designed for fundamentally different models of connection management, and the collision happens at a single PostgreSQL parameter: max_connections.

The Anatomy of the Crisis

max_connections

PostgreSQL's default max_connections is 100. Each connection spawns a dedicated backend process consuming approximately 5–10MB of shared memory. A small managed instance — AWS's db.t3.micro with 1GB of RAM — supports roughly 100 connections before memory pressure causes performance degradation. A larger instance — db.r5.large with 16GB — might support 500 to 1,000. These are not soft limits. Connection 101 receives FATAL: too many connections for role. The failure is not a slowdown. It is a refusal.

max_connections.sql
-- Check your current limit
SHOW max_connections;

-- Typical defaults and practical limits
-- db.t3.micro  (1 GB RAM):   ~100 connections
-- db.r5.large  (16 GB RAM):  ~500–1,000 connections

The Lambda multiplication problem

Each AWS Lambda invocation creates its own database connection. Lambda scales horizontally without consulting your database — when traffic spikes, AWS provisions as many concurrent instances as needed (up to your account's concurrency limit, which defaults to 1,000). A traffic spike producing 500 concurrent requests spawns 500 Lambda instances, each opening a connection to PostgreSQL. If your database supports 100 connections, 400 of those requests receive fatal errors. The arithmetic is unforgiving.

The situation compounds with ORMs. Prisma's default connection pool size is 1 + 2 × num_CPUs. Lambda reports 2 CPUs at the OS level regardless of memory allocation, giving a default of 5 connections per invocation. 500 Lambdas × 5 connections = 2,500 connection attempts against a database that supports 100. The crisis is not only about Lambda concurrency — it is about what each Lambda does with its connections.

prisma_pool_math.js
// Prisma's default connection pool size per Lambda:
// 1 + 2 * num_CPUs = 1 + 2 * 2 = 5 connections

// 500 Lambdas * 5 connections = 2,500 connection attempts
// Against a database that supports 100.

Zombie connections

When a Lambda function completes its work, AWS does not immediately destroy the container. It freezes the container for potential reuse — a performance optimization that avoids cold starts on subsequent invocations. The frozen container holds its database connection open. PostgreSQL sees an active connection occupying a slot and consuming memory. No queries arrive, but the connection persists.

The Lambda might be reused minutes later, at which point the existing connection is recycled — a welcome efficiency. Or it might be silently destroyed after 5 to 15 minutes, at which point the connection eventually times out. During a traffic spike, you accumulate zombie connections faster than they expire. The database's connection slots fill with processes that are waiting for queries that will never arrive.

I find this behaviour — creating connections without a mechanism for closing them — to be the infrastructural equivalent of opening every window in the house during winter and then complaining about the heating bill.

The serverless database cold start

Serverless PostgreSQL providers add another dimension. Neon's compute layer scales to zero after 5 minutes of inactivity — the instance literally shuts down. The first connection after scale-to-zero incurs a cold start of approximately 500 milliseconds to 2 seconds while the compute node is provisioned and PostgreSQL starts. If 50 Lambda functions simultaneously hit a cold Neon instance, the connection surge arrives precisely when the database is least prepared for it.

Supabase's architecture is different — compute does not scale to zero — but its shared pooler (Supavisor) still manages the relationship between potentially thousands of client connections and the fixed connection capacity of the underlying PostgreSQL instance.

Connection Pooling: The Principle

A connection pooler sits between your application and PostgreSQL. It maintains a small pool of real database connections — say, 20 — and multiplexes hundreds or thousands of client connections onto those 20. Client 501 does not need its own PostgreSQL backend process. It waits briefly until a pooled connection becomes available, borrows it for the duration of its query or transaction, then returns it.

The two modes that matter:

Session mode: The client receives a dedicated PostgreSQL connection for the entire session, until it disconnects. This is effectively what you already have without a pooler — useful for long-lived application servers, but it does nothing for the serverless connection crisis.

Transaction mode: The client receives a connection only for the duration of a single transaction. The moment the transaction completes, the connection is returned to the pool and immediately available for another client. This is the mode that makes serverless viable. A pool of 20 real connections can serve 1,000 concurrent Lambda functions, as long as each function's transaction is short.

Transaction mode has constraints. Prepared statements do not work across transaction boundaries — the statement was prepared on a backend process that may serve a different client on the next transaction. Session-level SET commands are lost between transactions. LISTEN/NOTIFY requires a persistent connection that transaction mode cannot provide. For the typical serverless use case — execute a query, return a result — these constraints are irrelevant. Transaction mode is the correct default.

ModeConnection AssignedPrepared StatementsSession VariablesServerless Fit
SessionEntire sessionYesYesPoor — no multiplexing
TransactionPer transactionNoNoExcellent — high multiplexing

The Solutions

PgBouncer: the standard

The original PostgreSQL connection pooler. A lightweight C daemon that has been in production since 2007. PgBouncer runs as a sidecar container, a standalone service, or built into managed platforms. Transaction mode is the correct configuration for serverless workloads. Supabase uses Supavisor as its default shared pooler, with PgBouncer available as a dedicated pooler on paid plans. Neon uses PgBouncer for its pooled connection strings.

PgBouncer is well-understood, battle-tested, and boring. In infrastructure, boring is the highest compliment available.

pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

; Transaction mode — the correct setting for serverless
pool_mode = transaction

; 20 real connections serve thousands of clients
default_pool_size = 20
max_client_conn = 1000

; Return connections quickly
server_idle_timeout = 30

AWS RDS Proxy

AWS's native connection pooler for Lambda-to-RDS/Aurora workloads. Handles IAM authentication, automatic failover awareness, and integrates with the AWS ecosystem seamlessly.

The catch: connection pinning with Prisma. When Prisma uses prepared statements — its default behaviour — RDS Proxy pins the connection to a specific PostgreSQL backend for the duration of the session. This negates the pooling benefit entirely, because the pinned connection cannot be shared with other clients. Prisma's own documentation states that RDS Proxy "does not provide any connection pooling benefits when used together with Prisma Client." The workaround is adding pgbouncer=true to the connection string, which disables prepared statements — ironic, given that you are not using PgBouncer. RDS Proxy also adds 1–5 milliseconds of latency per query, which is measurable for latency-sensitive workloads.

rds_proxy_prisma.js
// RDS Proxy + Prisma: disable prepared statements
// to avoid connection pinning
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL") // ?pgbouncer=true
}

// The ?pgbouncer=true flag disables prepared statements
// — ironic, given that you are not using PgBouncer.

Supavisor

Supabase's connection pooler, written in Elixir and designed for multi-tenant architectures. It replaces PgBouncer as the default pooler for Supabase's shared infrastructure, handling connection multiplexing for potentially thousands of projects on shared compute. Available in both transaction and session modes via separate connection strings in the Supabase dashboard.

For Supabase users, the integration is seamless: use the transaction mode connection string, set your ORM's pool size to 1 in each serverless function, and the crisis is resolved.

supavisor_config.js
// Supabase: use the transaction mode connection string
// Found in Dashboard → Settings → Database → Connection string → Transaction

// Pool mode: Transaction (port 6543)
const connectionString =
  "postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres";

// Set pool size to 1 in each serverless function
// The pooler handles multiplexing

Neon's serverless driver

A fundamentally different approach. Instead of pooling TCP connections, Neon provides a WebSocket-based and HTTP-based driver that eliminates persistent connections entirely. Each query is an HTTP request — no TCP connection to establish, no connection to pool, no connection to become a zombie. The @neondatabase/serverless npm package provides this interface for JavaScript/TypeScript applications.

This is particularly valuable for edge runtimes — Cloudflare Workers, Vercel Edge Functions — where TCP connections are either unavailable or prohibitively expensive. The trade-off: slightly higher per-query latency than a pooled TCP connection (HTTP overhead), and PostgreSQL features that require session state (LISTEN/NOTIFY, prepared statements, advisory locks) are not supported.

neon_serverless.js
import { neon } from '@neondatabase/serverless';

// No TCP connection. No pool. Each query is an HTTP request.
const sql = neon(process.env.DATABASE_URL);

// Works in edge runtimes where TCP is unavailable
const result = await sql`
  SELECT * FROM mv_dashboard_metrics
  WHERE day >= CURRENT_DATE - INTERVAL '30 days'
  ORDER BY day DESC
`;

Prisma Accelerate

Prisma Accelerate. Sits between Prisma Client and PostgreSQL on Prisma's infrastructure, pooling connections and adding a query caching layer. The integration is seamless if you are already using Prisma — change the connection string to point at Accelerate, and connection pooling is handled externally. For applications not using Prisma, it is not relevant.

Cloudflare Hyperdrive

An edge-based connection pooler that maintains persistent connections to your database from Cloudflare's global network. Your Workers and Pages functions connect to Hyperdrive at low latency (edge to edge), and Hyperdrive maintains a small pool of connections to PostgreSQL (edge to origin). Particularly effective for geographically distributed traffic connecting to a single-region database — the connection pooling and the latency reduction compound.

hyperdrive_config.toml
// wrangler.toml
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "your-hyperdrive-id"

// Worker code
export default {
  async fetch(request, env) {
    const connectionString = env.HYPERDRIVE.connectionString;
    // Use with any PostgreSQL driver
    // Hyperdrive pools connections at the edge
  }
};
SolutionTypeBest ForKey Trade-off
PgBouncerTCP poolerSelf-hosted, any platformMust deploy and manage separately
RDS ProxyTCP poolerAWS Lambda + RDS/AuroraConnection pinning with Prisma; 1–5ms added latency
SupavisorTCP poolerSupabase usersSupabase-only
Neon serverless driverHTTP/WebSocketEdge runtimes, Neon usersHigher per-query latency; no session state
Prisma AccelerateManaged poolerPrisma usersPrisma-only; external dependency
Cloudflare HyperdriveEdge poolerCloudflare Workers/PagesCloudflare-only

Why Materialized Views Make This Better

This section connects a point that I have not seen made elsewhere, and I believe it deserves attention.

Connection pooling and materialized views are not independent optimizations. They multiply each other.

A materialized view that serves a dashboard query in 7 milliseconds instead of 7,000 milliseconds holds its pooled connection for 0.007 seconds instead of 7 seconds. Consider a pool of 20 real connections. With 7-second queries, the pool can serve roughly 2.8 queries per second — each connection is occupied for 7 seconds before it can be reused. With 7-millisecond queries against a materialized view, the same 20 connections can serve approximately 2,857 queries per second. The pool turns over a thousand times faster.

pooling_throughput.sql
-- Without materialized view: 7-second query
-- Pool of 20 connections
-- Throughput: 20 / 7 = ~2.8 queries/second

-- With materialized view: 7-millisecond query
-- Same pool of 20 connections
-- Throughput: 20 / 0.007 = ~2,857 queries/second

-- The pool turns over 1,000x faster.

In a serverless context, this is the difference between a database that collapses under 100 concurrent Lambda functions and a database that handles 1,000 without distress.

Fewer queries compound the effect. If 80% of your serverless function invocations read pre-computed data from a materialized view, each invocation's database interaction is a single index scan — the fastest possible query type, the shortest possible connection hold time. The remaining 20% that perform writes or complex reads benefit from the pool capacity freed by the 80% that execute in milliseconds.

Connection pooling keeps you connected. Materialized views keep you fast. Together, they make a database with 100 max_connections serve architectures that were designed to overwhelm it.

Recommendations

If you are on Supabase: Use the transaction mode connection string (Supavisor). Set your ORM's pool size to 1 in each serverless function. This is the single most impactful configuration change you can make.

If you are on Neon: Use the @neondatabase/serverless driver for edge functions. Use the pooled connection string (PgBouncer) for Lambda or traditional serverless functions.

If you are on AWS (RDS or Aurora): RDS Proxy if you are not using Prisma. PgBouncer as a sidecar if you are — or disable prepared statements in your Prisma connection string.

If you are self-hosting PostgreSQL: PgBouncer in transaction mode is the proven default. PgCat if you need multi-threaded scaling or built-in replica failover. Chapter 17 compares the options in detail.

For everyone, regardless of platform: Set connection_limit=1 (or your ORM's equivalent) in every serverless function. Each function needs one connection, not the ORM's default pool. This single setting prevents the multiplication problem described earlier — 500 Lambdas × 1 connection = 500 attempts instead of 500 × 3 = 1,500.

connection_limit_one.js
// Set connection_limit=1 in every serverless function
// Prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL") // ?connection_limit=1
}

// Drizzle / node-postgres
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1
});

// SQLAlchemy
engine = create_engine(
    DATABASE_URL,
    pool_size=1,
    max_overflow=0
)

Connection pooling keeps the doors open. In the next chapter, we address the question that arrives once traffic is sustained: when should you shard your database — and how much longer can you avoid it?