Ask where a slow backend spends its time and the honest answer is almost always the same: talking to the database, and the network round trips to get there — not your business logic. So backend performance work is mostly database and I/O discipline, attacked in a specific order. The numbers below are measured (Python + SQLite micro-benchmarks); the ratios are what matter, and they’re brutal. First, measure — percentiles, not averages — then fix the hop that dominates.
The two database mistakes that dominate
Two problems cause more backend slowness than everything else combined, and both are easy to miss in code review because the code looks innocent.
N+1 queries — death by round trips
The classic ORM trap: you fetch a list, then loop and fetch related rows one at a time. 1000 users with a query each is 1001 queries. In-process against an in-memory DB that’s “only” ~4× slower — which is why it sneaks through. But each of those queries is really a network round trip to the database, and that’s where it kills you:
at 1 ms network RTT per query: N+1 = 1001 ms (1001 trips) vs single JOIN ≈ 2 ms → ~500×The fix is to load the related rows in one query — a JOIN, or a batched IN (…) (your ORM’s “eager loading” / “select_related” / “includes”). One round trip instead of a thousand.
# N+1: one query, then a query PER user → 1001 round trips
users = db.query("SELECT id, name FROM users")
for u in users:
posts = db.query("SELECT * FROM posts WHERE user_id = ?", u.id) # ← the trap
# Fixed: one query, one round trip
rows = db.query("""SELECT u.id, u.name, COUNT(p.id)
FROM users u LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id""")Missing indexes — full table scans
A query that filters or joins on an unindexed column scans every row. Measured on 200,000 rows:
SELECT COUNT(*) ... WHERE uid = ? full scan: 6.56 ms with index: 0.002 ms → 4,214× fasterIndex the columns you filter (WHERE) and join (ON) on — it’s the single highest-leverage backend change you can make. The catch: confirm the planner actually uses it with EXPLAIN, because a function on the column (WHERE lower(email) = …), a leading wildcard, or a type mismatch silently disables it. Verify, don’t assume.
Cache what’s hot — but only what’s hot
Recomputing something expensive on every request is waste if the inputs rarely change. Measured, a cache hit was ~47,000× faster than recomputing:
recompute: 7.9 ms cache hit: 0.17 µs → ~47,000×But caching is a correctness decision, not just a speed one. Only cache data that’s read far more often than it changes, and always have an invalidation plan (TTL, or event-based eviction) — a fast wrong answer is worse than a slow right one. Layer it: in-process memory first (nanoseconds), then a shared cache like Redis (sub-millisecond, shared across instances).
The order to attack backend latency
Do these in order — each is higher-leverage than the next:
- Kill N+1 — the #1 silent killer in ORM code. One query, not N.
- Index the columns you filter and join on; confirm with
EXPLAIN. - Cache read-heavy, change-rarely data, with an invalidation plan.
- Pool connections — opening a DB/TLS connection per request is pure overhead (a TCP + TLS handshake is several round trips before your query runs). Reuse them from a pool sized by Little’s Law.
- Then the rest — paginate (never return unbounded result sets), use async I/O so one slow downstream call doesn’t block a whole worker, batch external calls, and pick cheaper serialization (JSON encoding of large payloads is real CPU; stream it, trim fields, or use a faster encoder).
Follow one request through the backend
To know which of those to reach for, trace a real request end-to-end and see where the time actually goes.
Every hop adds latency. Give each a budget, then use distributed tracing to find the one that blows it — usually a chatty DB call, a cold cache, a serialization step, or queueing when a tier is saturated. Fix the dominant hop (Amdahl’s law: the others barely matter), and — the highest principle of all — push work off the request path: do it asynchronously, precompute it, or cache it, so the user waits for as little as possible.
Takeaways
- The database and its round trips dominate backend latency — not your logic. Optimize there first.
- Kill N+1 (one JOIN/batch, not a query per row — ~500× at realistic network latency).
- Index filter/join columns (4,214× measured) and confirm with
EXPLAIN. - Cache read-heavy, change-rarely data (~47,000×) — with an invalidation plan.
- Pool connections, then paginate, go async, and trim serialization.
- Trace a real request, fix the one hop that dominates, and move work off the request path.
Part of a series: start with Performance, Measured, then scale it in infrastructure performance, and see the AI-specific version in AI application performance. For deeper dives, see the database internals and Redis courses.
$ subscribe --weekly
One deep dive a week, in your inbox
System design, AI engineering, trading systems and interview prep. Tested code, real numbers. Free, and you can unsubscribe any time.
or read it on gyanbyte.substack.com












