Skip to content
Express.js ex data 5 min read

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:

OptionPurposeTypical value
maxMaximum connections this process will open5–20
minConnections kept warm even when idle0–2
idleTimeoutMillisHow long an unused connection lingers before closing30000
connectionTimeoutMillisHow long a query waits for a free connection before failing2000–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/catch and call next(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 max against your database’s connection limit divided by the number of app processes, with headroom for migrations and admin sessions.
  • Always set connectionTimeoutMillis so a saturated pool fails fast and visibly instead of hanging.
  • For transactions, check out an explicit client and release it in a finally block 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 SIGTERM and SIGINT: stop the server, drain the pool with pool.end(), then exit — never process.exit() mid-query.
  • Verify connectivity at boot (pool.query('SELECT 1')) so a misconfigured database fails the deploy instead of the first user request.
Last updated June 14, 2026
Was this helpful?