Connection Pooling & Management
Opening a database connection is one of the most expensive things an Express app does on the hot path: a TCP handshake, TLS negotiation, and authentication round-trips all happen before a single row is read. If you connect on every request, that cost dominates your latency and quickly exhausts the database’s connection limit. The fix is a connection pool — a small set of pre-opened connections created once at startup and reused across thousands of requests. This page covers why pooling matters, how to size a pool, how to share a single pool across your whole app, and how to drain it cleanly when the process shuts down.
Why pooling matters
A database server caps how many simultaneous connections it accepts (PostgreSQL defaults to 100, and managed plans are often far lower). Each connection also consumes memory on the server. Without a pool, a traffic spike spawns one connection per concurrent request, and you hit the cap, get too many connections errors, and cascade into failures.
A pool inverts this. You decide the maximum number of connections up front. Queries borrow a connection, run, and return it. When all connections are busy, new queries wait in a queue instead of opening more. This bounds your database load no matter how much traffic arrives, and it amortizes the connection cost across every query that reuses a connection.
Pooling is per Node.js process. If you run your app under a cluster with 4 workers and
max: 10, you have up to 40 connections to the database — size the pool with your process count in mind.
Configuring pool size
The right pool size is smaller than most people expect. Connections spend most of their time idle waiting on the database, so a handful of connections can serve a high request rate. A common starting point is the database’s connection budget divided by the number of app processes, leaving headroom for migrations and admin tools.
Here is a representative pg pool with the options you will most often tune:
// db.js
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // hard cap on connections in this process
min: 0, // keep no idle connections when quiet
idleTimeoutMillis: 30000, // release an idle client after 30s
connectionTimeoutMillis: 5000, // reject a query if none free in 5s
});
pool.on('error', (err) => {
// Errors on idle clients fire here, not on a query — log, do not crash
console.error('Unexpected idle client error', err);
});
module.exports = pool;
The key options and what they control:
| Option | Purpose | Typical value |
|---|---|---|
max | Maximum connections this process will open | 5–20 |
min | Connections kept warm even when idle | 0–2 |
idleTimeoutMillis | How long an unused connection lingers before closing | 30000 |
connectionTimeoutMillis | How long a query waits for a free connection before failing | 2000–10000 |
Setting connectionTimeoutMillis is what turns a silent hang into a fast, observable error when the pool is saturated — always set it.
Reusing one pool across the app
Create the pool once and import it wherever you need it. Module caching in Node.js guarantees the require('./db') above returns the same Pool instance everywhere, so you never accidentally create a second pool. Route handlers simply borrow from it:
// routes/users.js
const express = require('express');
const pool = require('../db');
const router = express.Router();
router.get('/:id', async (req, res, next) => {
try {
// pool.query checks out a connection, runs, and returns it automatically
const { rows } = await pool.query(
'SELECT id, email FROM users WHERE id = $1',
[req.params.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(rows[0]);
} catch (err) {
next(err); // forward to the error-handling middleware
}
});
module.exports = router;
For multi-statement work like transactions, check out an explicit client and always release it in a finally block so a thrown error can never leak a connection back out of the pool:
router.post('/transfer', async (req, res, next) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [req.body.amount, req.body.from]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [req.body.amount, req.body.to]);
await client.query('COMMIT');
res.json({ ok: true });
} catch (err) {
await client.query('ROLLBACK');
next(err);
} finally {
client.release(); // return the connection no matter what
}
});
Closing connections on shutdown
When a process receives SIGTERM (from a container orchestrator, a deploy, or Ctrl+C as SIGINT), it should stop accepting new requests, finish in-flight ones, and then drain the pool. Killing the process without draining can drop open transactions and leave the database holding stale connections.
// server.js
const express = require('express');
const pool = require('./db');
const app = express();
app.use(express.json());
app.use('/users', require('./routes/users'));
const server = app.listen(3000, () => console.log('Listening on :3000'));
async function shutdown(signal) {
console.log(`${signal} received, shutting down`);
server.close(async () => { // stop accepting new connections
await pool.end(); // wait for active queries, then close all
console.log('Pool drained, exiting');
process.exit(0);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Output:
Listening on :3000
SIGTERM received, shutting down
Pool drained, exiting
server.close() stops new connections while letting open requests finish, and pool.end() waits for any borrowed clients to be returned before closing every connection. The same pattern applies to other drivers: Mongoose exposes mongoose.connection.close(), the native MongoDB driver uses client.close(), and Prisma uses prisma.$disconnect().
In Express 5, async errors thrown in handlers are forwarded to your error middleware automatically. In Express 4 you must
try/catchand callnext(err)yourself — as shown above — or the connection borrowed for that request may never be released.
Best Practices
- Create exactly one pool (or client) per process at startup and share it via a module export — never connect inside a request handler.
- Size
maxagainst your database’s connection limit divided by the number of app processes, with headroom for migrations and admin sessions. - Always set
connectionTimeoutMillisso a saturated pool fails fast and visibly instead of hanging. - For transactions, check out an explicit client and release it in a
finallyblock so errors cannot leak connections. - Attach a
pool.on('error', ...)handler so background errors on idle connections are logged rather than crashing the process. - Handle
SIGTERMandSIGINT: stop the server, drain the pool withpool.end(), then exit — neverprocess.exit()mid-query. - Verify connectivity at boot (
pool.query('SELECT 1')) so a misconfigured database fails the deploy instead of the first user request.