Skip to content
← Back to blog

Debugging · 2026-08-17 · 3 min read

How 5 Users Took Down My Dashboard — Debugging a Postgres Connection Pool Exhaustion

A dashboard broke the moment 4-5 people opened it at once. The pool size wasn't the problem — N+1 queries hiding inside a handful of APIs were.

Share

I built a dashboard screen that pulled data from 6-7 different API endpoints to render all its widgets — stats cards, tables, charts, all fetched in parallel when the page loaded. It worked great in every test I ran. Then it went live, and the first time 4-5 people opened it at the same time, everything fell apart.

Requests started timing out. My logs were full of a message I hadn't seen before: Postgres refusing new connections, too many clients already. My first reaction was confusion — this wasn't a high-traffic app. Four or five people shouldn't be enough to exhaust a connection pool.

The first (wrong) theory

My first instinct was that the pool size was just set too low. Sequelize's default pool config is conservative, so I bumped it up and redeployed, hoping that would buy some breathing room:

// db.js
const sequelize = new Sequelize(process.env.DATABASE_URL, {
  dialect: "postgres",
  pool: {
    max: 20,      // bumped from the default 5
    min: 0,
    acquire: 30000,
    idle: 10000,
  },
});

It helped a little — delayed the failure — but the same error came back as soon as a few more people loaded the dashboard. That told me the real problem wasn't the pool size. It was how many connections each request was actually using.

n-plus-1-problem.svg

Counting what was actually happening

Instead of guessing again, I turned on Sequelize's query logging and watched what a single dashboard load actually fired against Postgres:

const sequelize = new Sequelize(process.env.DATABASE_URL, {
  dialect: "postgres",
  logging: (sql) => console.log(sql), // temporary — just to count queries
});

What I expected: 6-7 requests, roughly 6-7 queries. What I actually saw in the logs: some of those APIs alone were firing dozens of queries for a single request.

The pattern was classic N+1 — fetching a list of records, then looping over that list and firing a separate query for each item's related data:

// the bug — one extra query per order
const orders = await Order.findAll();
for (const order of orders) {
  order.customer = await Customer.findByPk(order.customerId);
}

Multiply that across several of the 6-7 endpoints, running in parallel for even a handful of users, and the dashboard wasn't asking Postgres for "5 users' worth" of connections. It was asking for connections proportional to users × endpoints × items-per-list — a number that scaled far faster than I'd accounted for.

The actual fix

The fix wasn't more connections — it was fewer queries. I went through each of the 7 APIs and replaced the loop-and-query pattern with Sequelize's include, so related data came back in one joined query instead of one query per row:

// the fix — one query, joined
const orders = await Order.findAll({
  include: [{ model: Customer, as: "customer" }],
});

eager-loading-fix.svg

The real number of concurrent connections dropped hard, and the timeouts disappeared — with no change to the pool size at all.

What I took away from it

Request count is not query count, and it's dangerous to reason about database load using the former. Five people loading a page tells you almost nothing about how many actual queries that page fires — you have to go and count. If I'd trusted my first theory and just kept bumping the pool size, I'd have kept throwing bigger numbers at a problem that had nothing to do with pool size at all.