The Database Is the Bottleneck. Always.
database indexing n+1 query-optimization connection-pooling
The Metrics Lied
It was a Tuesday morning standup. The on-call engineer pulled up the dashboards to explain why the site had been sluggish since 9 AM.
“CPU is fine,” she said. “10% across all app servers. Memory is at 40%. No obvious errors.”
The team stared at the graphs for a moment.
“So what’s wrong?” the tech lead asked.
She switched to the database panel. Every engineer in the room leaned forward slightly.
The DB had 8,147 open connections. The connection pool max was 1,000. The wait queue was showing 7,000 connections sitting idle, unable to acquire a slot. Average query time had crept from 8ms to 1,200ms over the past 90 minutes. The slow query log was a wall of text.
The application servers were fine. They were fine because they were waiting. They had sent their requests to the database and were sitting there, blocked, holding threads, unable to do anything else. The CPU looked healthy because your servers were doing nothing.
This is the most common misreading in backend engineering: the application layer appears calm precisely because the database is the one that’s drowning.
Why the Database Is Always the Bottleneck
Application servers are stateless. You can scale them horizontally with no coordination overhead. Add an app server, plug it into the load balancer, and it starts handling requests in minutes.
Databases are stateful. They maintain ACID guarantees, manage indexes, hold locks, coordinate transactions. That statefulness is what makes them valuable. It is also what makes them the irreplaceable, non-horizontally-scalable centerpiece of every system.
Here is the failure chain that played out that Tuesday morning:
Code shipped Monday: new feature with N+1 query
→ Each API request fires 80+ queries instead of 3
→ Query count multiplies 26x
→ DB connection usage spikes
→ Connection pool fills (1,000 max connections)
→ New requests queue for a connection
→ App server threads block, waiting
→ Response times rise from 40ms to 1,200ms
→ Users retry → more load → deeper queue
→ Site feels broken despite green CPU
The original code looked like this:
async function getOrderSummary(userId) {
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ?', [userId]
);
// Fires once per order. For a user with 80 orders: 80 queries.
for (const order of orders) {
order.items = await db.query(
'SELECT * FROM order_items WHERE order_id = ?', [order.id]
);
order.shipping = await db.query(
'SELECT * FROM shipments WHERE order_id = ?', [order.id]
);
}
return orders;
}
For a user with 80 orders, this fires 161 queries. At 100 concurrent users, that is 16,100 queries per request cycle. At 8ms per query, that is 129 seconds of DB time per second of wall clock. The math is not in your favor.
Diagnosing the Database Under Load
Before changing anything, you need to see what is actually happening inside the database. Three tools get you there.
1. The Active Query View
In PostgreSQL:
SELECT pid, now() - pg_stat_activity.query_start AS duration,
query, state
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC
LIMIT 20;
This shows you every query currently running, sorted by how long it has been running. If you see the same query pattern repeated hundreds of times, you have found your N+1.
2. EXPLAIN ANALYZE
Run this on any query taking more than 50ms in the slow query log:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM order_items
WHERE order_id = 4829;
Bad output:
Seq Scan on order_items (cost=0.00..94221.00)
Filter: (order_id = 4829)
Rows Removed by Filter: 2,847,221
Execution Time: 748ms
Good output (after adding index):
Index Scan using idx_order_items_order_id on order_items
Index Cond: (order_id = 4829)
Rows Removed by Index Recheck: 0
Execution Time: 0.3ms
That is a 2,493x improvement on a single query. The missing index was costing every call 748ms.
3. Connection and Lock Statistics
-- How many connections are waiting for locks?
SELECT count(*), wait_event_type, wait_event
FROM pg_stat_activity
WHERE wait_event IS NOT NULL
GROUP BY wait_event_type, wait_event
ORDER BY count DESC;
-- How saturated is the connection pool?
SELECT count(*) as total,
sum(case when state = 'active' then 1 else 0 end) as active,
sum(case when state = 'idle' then 1 else 0 end) as idle
FROM pg_stat_activity;
If you see hundreds of connections in ClientRead or Lock state, your pool is exhausted and requests are queuing.
Fix 1: The N+1 Elimination
The highest-leverage fix is always the query count. One well-written query beats a hundred poorly-written queries every time.
// Before: 1 + (N * 2) queries
async function getOrderSummary(userId) {
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ?', [userId]
);
for (const order of orders) {
order.items = await db.query(...)
order.shipping = await db.query(...)
}
return orders;
}
// After: exactly 3 queries regardless of order count
async function getOrderSummary(userId) {
const [orders, items, shipments] = await Promise.all([
db.query('SELECT * FROM orders WHERE user_id = ?', [userId]),
db.query(`
SELECT oi.* FROM order_items oi
INNER JOIN orders o ON o.id = oi.order_id
WHERE o.user_id = ?
`, [userId]),
db.query(`
SELECT s.* FROM shipments s
INNER JOIN orders o ON o.id = s.order_id
WHERE o.user_id = ?
`, [userId]),
]);
const itemsByOrder = groupBy(items, 'order_id');
const shipByOrder = groupBy(shipments, 'order_id');
return orders.map(o => ({
...o,
items: itemsByOrder[o.id] ?? [],
shipping: shipByOrder[o.id] ?? null,
}));
}
The three queries now run in parallel (Promise.all). Total time drops from 161 sequential queries at 8ms each (1,288ms) to 3 parallel queries at ~12ms each (12ms). That is a 107x improvement.
Fix 2: Index Design
Indexes are the most impactful single-row change you can make to a database. A correct index on a filtered column transforms a full table scan into a pointer lookup.
Table: orders (2,400,000 rows)
Without index on (user_id):
Query: SELECT * FROM orders WHERE user_id = 12345
Plan: Seq Scan — reads all 2.4M rows, discards 2,399,967
Cost: 450ms
With index on (user_id):
Query: SELECT * FROM orders WHERE user_id = 12345
Plan: Index Scan — reads only matching rows (33 rows)
Cost: 0.8ms
Composite index design:
-- Query pattern: filter by user, sort by date, paginate
SELECT * FROM orders
WHERE user_id = 12345
AND status = 'shipped'
ORDER BY created_at DESC
LIMIT 20;
-- Correct composite index:
-- Equality columns first, then range/sort columns
CREATE INDEX idx_orders_user_status_date
ON orders (user_id, status, created_at DESC);
Partial indexes for selective data:
-- Only index active sessions, not the 98% that are expired
CREATE INDEX idx_sessions_active
ON sessions (user_id, created_at)
WHERE expires_at > NOW();
This index is dramatically smaller and faster than a full index on the same columns.
Fix 3: Connection Pooling
Every database connection costs memory and CPU. PostgreSQL recommends no more than a few hundred active connections. Running 8,000 is not scaling — it is resource exhaustion.
The fix is a connection pooler that sits between your application servers and the database:
Before:
[App Server 1] → 200 connections → [PostgreSQL]
[App Server 2] → 200 connections → [PostgreSQL]
[App Server 3] → 200 connections → [PostgreSQL]
Total: 600 raw connections to DB
After (with PgBouncer):
[App Server 1] → 200 connections → [PgBouncer] → 50 connections → [PostgreSQL]
[App Server 2] → 200 connections → │
[App Server 3] → 200 connections → ─────┘
Total: 50 real connections. App thinks it has 600.
PgBouncer in transaction mode holds a real DB connection only for the duration of a single transaction. When the transaction commits, the connection is returned to the pool and immediately available for another app server’s request. Thousands of application connections multiplex into tens of database connections.
; pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 50
reserve_pool_size = 10
server_idle_timeout = 60
This single configuration file brought the Tuesday incident from 8,147 connections down to 50, with no application code changes required.
Fix 4: Read Replicas for Read Traffic
Writes must go to the primary. Reads do not.
Most production databases are 80 to 95% reads. Routing all of that read traffic to the primary when read replicas can serve it is a significant waste of capacity.
Write path:
API → Primary DB (INSERT / UPDATE / DELETE)
Read path:
API → Read Replica (SELECT)
→ Replica handles the load
→ Primary has headroom for writes
┌──────────────────────────────────────────────┐
│ Application Layer │
└────────┬──────────────────────┬──────────────┘
│ writes │ reads
▼ ▼
┌────────────────┐ ┌──────────────────────┐
│ Primary DB │───▶│ Read Replica 1 │
│ (writes only) │ │ Read Replica 2 │
└────────────────┘ │ (SELECT traffic) │
└──────────────────────┘
The tradeoff is replication lag. A replica might be 10 to 500 milliseconds behind the primary. For most reads (user profiles, product listings, historical data) this is fine. For reads that must reflect a just-completed write (reading your own profile after updating it), route to the primary or use a short cache invalidation strategy.
The Full Picture After the Fix
Here is what the architecture looked like after the Tuesday incident was resolved, and after the week of follow-up work:
┌────────────────────────────────────────────────────────────────────┐
│ Application Servers │
│ (stateless, horizontally scaled, auto-scaled by request rate) │
└───────────────┬──────────────────────────────────────┬─────────────┘
│ │
writes (5%) reads (95%)
│ │
┌───────────────▼──────────┐ ┌───────────────▼──────────────┐
│ PgBouncer (Primary) │ │ PgBouncer (Replica Pool) │
│ pool_size: 50 │ │ pool_size: 100 │
└───────────────┬──────────┘ └───────────────┬──────────────┘
│ │
┌───────────────▼──────────┐ ┌───────────────▼──────────────┐
│ PostgreSQL Primary │──── replication ──▶ Read Replica 1 │
│ Writes + MVCC │ │ Read Replica 2 │
│ max_connections: 200 │ └────────────────────┘
└──────────────────────────┘
Results after one week:
| Metric | Before | After |
|---|---|---|
| DB connections | 8,147 | 50 |
| Avg query time | 1,200ms | 9ms |
| Queries per request | 161 | 3 |
| DB CPU | 91% | 14% |
| p99 API latency | 4,200ms | 85ms |
| Site errors (timeouts) | 3.2% | 0.01% |
Key Takeaways
- Low CPU does not mean the system is healthy. A waiting application looks idle. Check the database, not just the app servers.
- The N+1 query problem multiplies with data volume. What runs fine in staging with 500 rows can destroy production with 2 million.
EXPLAIN ANALYZEis the most important tool in your database toolbox. Run it on every slow query before adding any index.- Connection pooling is not optional at scale. One connection pooler in front of the primary can reduce your DB connection count by 10x with zero application changes.
- Read replicas protect the primary. Route reads away from the write path to give your primary the headroom it needs for transactions.
FAQ
Q: How do I detect N+1 queries in production without reading every line of code? Enable the slow query log and look for the same query pattern firing hundreds of times per second with slightly different parameters. Query monitoring tools like pgBadger or Datadog Database Monitoring visualize this directly.
Q: How many connections should I allow per PostgreSQL instance?
A common guideline is max_connections = (2 * core_count) + effective_disk_count, but in practice, most production instances run well with 100 to 300 connections, with a pooler handling the fan-out from application servers. More than 500 raw connections degrades performance significantly.
Q: Can I have replication lag of zero? Not with asynchronous replication. You can use synchronous replication, but it increases write latency because the primary waits for at least one replica to acknowledge every commit. Most teams accept async replication with business logic to route sensitive reads to the primary.
Q: When should I add a partial index instead of a full index?
When only a small percentage of rows match the index predicate. An index on status = 'pending' is useful if 2% of orders are pending. It would be a waste if 80% of orders are pending.
Interview Questions
“The database is slow. Where do you start?”
Run EXPLAIN ANALYZE on the slowest query in the log. Look for Seq Scan on large tables as the first sign of a missing index. Check active connections and wait events. Identify if there is an N+1 pattern by looking for repeated identical query structures.
“How does a connection pool work?” A connection pool maintains a fixed set of real database connections and lends them to application threads on demand. When the thread’s transaction completes, the connection is returned to the pool. This allows thousands of application threads to share tens of database connections without each needing a dedicated one.
“What is the difference between an index scan and a sequential scan?” A sequential scan reads every row in the table and discards rows that do not match the filter. An index scan follows a B-tree structure to jump directly to matching rows. For selective queries (few matching rows), an index scan is orders of magnitude faster.
“How do you prevent read replica lag from causing incorrect behavior?” Route writes and their immediate subsequent reads to the primary. Use read-your-own-writes consistency by tagging sessions that have recently written and sending their reads to the primary for a short window (typically 1 to 5 seconds) afterward.
“How would you scale a database beyond a single primary?” Short term: read replicas for read traffic, connection pooling, query optimization. Medium term: caching layer to reduce DB load. Long term: application-level sharding by tenant ID or user ID range, or migrating to a distributed database like CockroachDB or Vitess that handles horizontal scaling natively.
This article is premium
One-time payment · Lifetime access to all premium content
Get Premium AccessAlready have access? Sign in