The serverless-Postgres survival kit (the exact 2-line fix + why)
My database hung up every five minutes and my server died of surprise. Neon (like most
serverless Postgres) closes idle TCP connections server-side after 3-5 minutes. My pg.Pool
kept handing out those dead connections; the connection's async error event had no listener;
Node treats an unhandled 'error' as uncaughtException; the whole serverless instance died,
and the next user paid the cold start. Two lines fixed it.
The exact fix
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
idleTimeoutMillis: 10_000, // line 1: WE close idle conns before Neon does
});
pool.on('error', (err) => { // line 2: async conn failures ≠ process death
console.warn('pg pool error (connection retired)', err.message);
});
Why each line matters
idleTimeoutMillis: 10_000: the race is "who closes the idle connection first." If Neon wins (3-5 min), your pool holds a corpse and the next query finds out. If you win (10s), the pool retires connections cleanly and opens fresh ones on demand.pool.on('error', ...): a pooled connection can fail while idle, between queries. That error is emitted on the pool, not thrown to any caller. No listener →uncaughtException→ process death → cold start for whoever's next. With the listener, the pool logs a warning and retires the bad client. It already knows how. It just needs you to not crash first.
The rest of the survival kit
- Do NOT "fix" this with a keepalive cron. Pinging the DB to keep connections warm defeats
auto-suspend and burns your compute quota. I have a separate outage story about exactly
that (
settl.fyi/social/cron-outage). - Expect the first request after idle to reconnect. A fresh handshake in-region is ~30ms. That's the correct price; pay it.
- Keep pool sizes tiny on serverless functions. Many concurrent instances × big pools = connection-limit blowout. 1-5 connections per instance, or use the provider's pooled connection string (pgbouncer).
- Watch for the signature in your error tracker:
Connection terminated unexpectedlywith anonuncaughtexceptionmechanism. That exact pair = this exact bug. - Test it deliberately: deploy, wait 6 idle minutes, fire one request. If p99 spikes or the instance restarts, you haven't fixed it.
Steal this for your app
- Any client-side pool talking to any serverless/managed DB (Neon, Supabase, RDS Proxy, PlanetScale) has this race. The two lines translate to every stack: set idle timeout below the server's, and handle async errors.
- More generally: every long-lived resource (DB, Redis, WebSocket) in Node needs an
errorlistener, or it's a scheduled process crash. - "Worked fine all day, died after lunch" bugs are idle-timeout bugs until proven otherwise.
Run this on your codebase
Paste this into Claude Code in your repo:
Audit this repo for the idle-connection crash.
Find every pg.Pool (or equivalent client pool) and check whether idleTimeoutMillis is set below the provider's server-side idle close window (Neon and Supabase close at 3-5 minutes).
Check each pool for a pool.on('error') handler; flag any pool without one as a scheduled process crash.
List every other long-lived resource (Redis client, WebSocket, queue consumer) missing an error listener.
Check pool max size against a serverless deployment; flag anything over 5 connections per instance without a pgbouncer-style pooled connection string.
Search for keepalive crons or warmers that ping the DB and flag them.
Report findings as a checklist before changing anything.
CONNECT: budget connections before production does it for you
Pool size is not a per-process preference. It is a database-wide budget. Write the constraint down:
(maximum concurrent app instances × pool size)
+ background workers + migrations + admin reserve
<= database connection cap - safety headroom
Use maximum concurrent instances, not the number running during a quiet test. Then load-test that many instances together and watch active connections, wait time, rejected checkouts, and database CPU. Alert before the hard cap, with enough headroom for a migration or incident investigation.
If the multiplication does not fit, put a pooler in front of the database. Transaction pooling is usually right for short stateless queries. Session pooling is required when code depends on session-level state, prepared statements, temporary tables, or advisory locks. Test those assumptions explicitly instead of treating every pooled URL as interchangeable.
Download the runnable pack
The pack turns the connection-budget inputs into a repeatable dry-run and includes an n8n endpoint, Docker Compose environment, and GitHub Actions validation. No database is changed by the sample run.