The database gets blamed for a lot of things it didn't do.
"Postgres is slow." "We need to add a cache." "Let's move this to Redis."
Sometimes those are the right calls. But more often than not, the database is doing exactly what you asked — and what you asked for was inefficient.
The Misdiagnosis
Here's the pattern I've seen more than once: a developer adds console.time around a
slow endpoint, sees 400ms, assumes the database is the bottleneck, and reaches for a
caching layer. The cache is added. The 400ms is gone. Six months later there are cache
invalidation bugs, stale data in production, and two engineers are on a Slack call at
11pm during an incident.
The root cause was never explored.
What EXPLAIN ANALYZE Tells You
The output includes:
- Seq Scan vs Index Scan — a sequential scan on a million-row table costs you
- Rows Removed by Filter — how many rows were read but thrown away
- Actual rows vs. predicted rows — a large gap means stale statistics; run
ANALYZE - Nested Loop vs. Hash Join — nested loops on unindexed joins are quadratic
The Index Is a Data Structure
An index is a B-tree (or hash, or GiST, or...) maintained alongside your table. Writes become slightly more expensive. Reads on indexed columns become dramatically faster. The trade-off is almost always worth it for columns you filter or sort on.
CONCURRENTLY is your friend. It builds the index without locking the table for writes.
N+1 Is Everywhere
The N+1 query is the most common source of slow endpoints I've seen. You fetch 20 posts, then loop through them and fetch the author for each one. You've just made 21 queries instead of 1.
ORM tools like Prisma and TypeORM have "eager loading" features (include, relations)
that handle this, but you need to know to use them. And you should understand the SQL
they generate.
Pagination That Actually Scales
OFFSET pagination is simple to implement and breaks at scale.
With OFFSET 10000, Postgres reads 10,020 rows and discards 10,000 of them. Every time.
With keyset pagination, it starts reading exactly where you left off.
When the Cache Is the Right Answer
Sometimes caching is genuinely the right call:
- Expensive aggregations computed from millions of rows that users need in real-time
- External API responses you have no control over
- Computed results that are expensive and rarely change (e.g., a user's follower count)
But add the cache after you understand the query. Not before.
What I Actually Check First
When debugging a slow endpoint:
- Is it actually the DB? — Add timing spans around the query and the full handler separately
- What does
EXPLAIN ANALYZEsay? — Sequential scans and high row estimates are red flags - Are there missing indexes? — Check the
WHERE,JOIN, andORDER BYcolumns - N+1? — Count the queries hitting the DB for a single request
- Connection pool exhausted? — If queries are fast but the endpoint is slow, you may be waiting for a connection
The database is usually doing exactly what you told it to do. Make sure you told it the right thing.
Jason Lima
Software engineer. I write about building systems, the craft of code, and lessons learned from shipping real products. Say hello →
Related reading
The Hidden Cost of 'Simple' Architecture Decisions
Every architecture decision you make in the first six months will still be there in year three. Here's how to think about the ones that compound.
On Writing Code That Explains Itself
Self-documenting code is not about avoiding comments — it's about making the code itself tell the story clearly enough that comments become optional.