🔌 Node.js Connection Pooling: Stop Opening a New Database Connection for Every Request
Picture this: your Express API launches, the first request comes in, and your app dutifully opens a fresh database connection to handle it. Request done, connection closed. Next request? Fresh connection. And so on, forever.
This is the database equivalent of hiring a new delivery driver for every single pizza order and firing them the moment they return. It's wildly inefficient, and your database will eventually stage a protest in the form of too many connections errors.
Connection pooling is the fix — and honestly, it's one of those backend fundamentals that separates "it works on my machine" code from software that survives real traffic.
What Even Is a Connection Pool?
A connection pool is a cache of open database connections that your app reuses across multiple requests instead of creating and destroying them constantly.
Think of it like a coffee shop with a fixed number of mugs. When a customer (request) wants coffee, they grab an available mug (connection), use it, and put it back. If all mugs are in use, they wait. No new mugs are manufactured for every customer — that would be chaos and a fire hazard.
Opening a database connection is surprisingly expensive:
- TCP handshake between your app and DB server
- TLS negotiation (if you're doing it right)
- Authentication — username, password, permissions
- Session setup — character encoding, timezone, default schema
For a simple query that takes 2ms, the connection setup might add another 10–50ms. At scale, that overhead becomes the bottleneck, not your query.
The Wrong Way (That Everyone Has Done)
Here's code you'll find in countless tutorials:
// ❌ The "works in development, cries in production" pattern
const { Client } = require('pg');
app.get('/users', async (req, res) => {
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
await client.connect(); // Opens a brand new connection every time
const result = await client.query('SELECT * FROM users LIMIT 10');
await client.end(); // Closes it immediately after
res.json(result.rows);
});
At low traffic this looks fine. At 200 concurrent requests, you've just tried to open 200 simultaneous database connections. PostgreSQL's default max_connections is 100. You're now in a bad place.
The Right Way: Use a Pool
Most database drivers ship with pooling built in. For PostgreSQL, pg gives you the Pool class. The key insight is: create the pool once at startup, share it across all requests.
// ✅ Create once, use everywhere
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Max connections in the pool
min: 2, // Keep at least 2 alive when idle
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 2000, // Fail fast if no connection available
});
// Export and reuse this pool everywhere
module.exports = pool;
// In your route handler — clean, simple, safe
const pool = require('../db/pool');
app.get('/users', async (req, res) => {
// pool.query() automatically checks out a connection,
// runs the query, and returns it to the pool
const result = await pool.query('SELECT * FROM users LIMIT 10');
res.json(result.rows);
});
That's it. The pool manages acquiring and releasing connections for you. No connect(), no end() — just query and go.
Sizing Your Pool: The Art of "Goldilocks Configuration"
Too few connections and your app queues up requests waiting for a free slot. Too many and you overwhelm your database. Most teams just leave the default and wonder why their app is slow.
A solid starting formula for max pool size:
max_connections = (number of CPU cores on DB server × 2) + number of disk spindles
For a modern 4-core DB server with SSDs, that's roughly 8–10 connections per app instance. If you're running 5 app instances (pods in Kubernetes, for example), that's 40–50 total connections — well within PostgreSQL's default limit.
The practical rule: your pool size should match your DB server's ability to do work, not your app's desire to be fast. A bigger pool doesn't mean faster queries — it means more connections competing for the same DB resources.
Also configure these two timeouts — they'll save you from silent hangs:
connectionTimeoutMillis: How long to wait for a free connection from the pool before erroring out. Set this to something sane (2–5 seconds) so a connection crunch surfaces as a real error, not a frozen request.idleTimeoutMillis: How long a connection can sit unused before the pool closes it. Prevents stale connections piling up when traffic drops.
Monitoring Your Pool Health
A pool you can't observe is a pool that'll surprise you at 3am. Most pool libraries expose stats — check them in your health endpoint:
app.get('/health', (req, res) => {
const { totalCount, idleCount, waitingCount } = pool;
// waitingCount > 0 means requests are queued waiting for a connection
// That's a warning sign you need to tune your pool or DB
res.json({
status: waitingCount === 0 ? 'healthy' : 'degraded',
pool: {
total: totalCount,
idle: idleCount,
waiting: waitingCount,
},
});
});
If waitingCount is consistently above zero during normal traffic, your pool is undersized relative to your query throughput. Time to either increase max, optimize slow queries, add a read replica, or all three.
A Few Gotchas to Know
Transactions need one connection for their duration. Use pool.connect() to check out a dedicated client when running multi-step transactions. Releasing it afterward is non-negotiable — leaked connections are a slow-motion disaster.
Each app process has its own pool. If you're running 10 Node.js workers via PM2 or 10 Kubernetes pods, each has its own max: 10 pool. Your DB sees 100 total connections. Plan accordingly, or use a connection pooler like PgBouncer between your app and DB to handle connection multiplexing at the infrastructure level.
Serverless environments are tricky. AWS Lambda, Vercel Functions, and similar platforms spin up fresh instances per invocation and don't share pools between them. Connection pooling in serverless means using a dedicated pooler (RDS Proxy, PgBouncer, Supabase's connection pooling). Don't try to manage it yourself — you'll lose.
The Payoff
Switching from new-connection-per-request to a properly sized pool typically brings:
- 50–200ms off your average response time from eliminated connection overhead
- Dramatically higher request throughput before your DB buckles
- Fewer
ECONNRESETandtoo many connectionserrors in production logs - A DBA who no longer sends you passive-aggressive Slack messages
Connection pooling is one of those changes that takes 30 minutes to implement and immediately makes your app feel noticeably faster. It's not glamorous, but neither is debugging a database meltdown at midnight.
Set up your pool, size it sensibly, monitor waitingCount, and move on to building features. Your database will thank you — quietly, professionally, in the form of not dying.
What's your go-to database driver and pool setup for Node.js? Drop it in the comments — I'm especially curious how folks handle pooling in serverless environments without losing their minds.