~/backend/backend-performance-optimization.md

Backend Performance: Kill N+1, Add the Index, Cache What's Hot

4 min readNovember 01, 2026by Gorav Singal#backend#performance#database
// TL;DR

Backend latency is almost always dominated by the database and the network between you and it — not your business logic. Attack it in order: (1) KILL N+1 queries — 1000 users + a query per user is 1001 round trips; in-process that's ~4× slower, but each query is really a network round trip, so at 1 ms RTT it's ~1001 ms vs ~2 ms, roughly 500×. Load related rows in one JOIN or a batched IN(…). (2) ADD INDEXES on the columns you filter and join on — a WHERE on 200k rows went from 6.56 ms (full scan) to 0.002 ms indexed, 4,214× faster, measured — then confirm the planner uses it with EXPLAIN. (3) CACHE read-heavy, change-rarely data (recompute 7.9 ms vs cache hit 0.17 µs, ~47,000×) with a real invalidation plan. (4) POOL connections so you're not paying TCP+TLS setup per request. (5) Then pagination, async I/O for fan-out, and cheaper serialization. Measure first (percentiles, not averages) and fix the one hop that dominates.

Backend Performance: Kill N+1, Add the Index, Cache What's Hot

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.

The two database mistakes that dominate: on the left, N+1 queries — one query for the list then one more per row, so 1000 users becomes 1001 queries; in-process it's about 4× slower, but each query is a network round trip, so at 1 ms RTT it's 1001 ms versus about 2 ms, roughly 500×, fixed with one JOIN or a batch load. On the right, a missing index: SELECT WHERE uid on 200k rows is 6.56 ms with a full scan versus 0.002 ms with an index, 4,214× faster, measured — the single highest-leverage backend fix. Plus caching: recompute 7.9 ms versus cache hit 0.17 microseconds, about 47,000×, but only for data read far more than it changes.

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× faster

Index 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:

  1. Kill N+1 — the #1 silent killer in ORM code. One query, not N.
  2. Index the columns you filter and join on; confirm with EXPLAIN.
  3. Cache read-heavy, change-rarely data, with an invalidation plan.
  4. 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.
  5. 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.

Follow one request through the backend: it passes through the client (TLS, DNS, network RTT), the load balancer (plus queueing if saturated), the app server (your code, serialization, GC pauses), the cache (hit about 1 ms, miss falls through to the DB), and the database (query plus index, lock waits, pool waits). The slowest hop sets your latency, so optimise that one, not the fast ones — Amdahl again. Budget each hop, trace a real request to find the dominant one, fix it, re-measure, and push work off the request path by doing it async, precomputing it, or caching it.

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

Share

Related Posts

Performance Optimization and Profiling in Node.js

Performance Optimization and Profiling in Node.js

Profiling First, Optimize Second Never optimize blindly. Always profile to find…

Scaling Infrastructure: The Edge, Horizontal Scale, and the Math Behind Them

Scaling Infrastructure: The Edge, Horizontal Scale, and the Math Behind Them

Application-level fixes (kill the N+1, add the index) get you a long way, but…

Redis — Caching, Sessions, Pub/Sub in Node.js

Redis — Caching, Sessions, Pub/Sub in Node.js

Why Redis for Node.js Redis is an in-memory data store that serves as a cache…

Database Integration — PostgreSQL with Node.js

Database Integration — PostgreSQL with Node.js

Choosing Your PostgreSQL Client Node.js has three main approaches to working…

Node.js Architecture — Event Loop Deep Dive

Node.js Architecture — Event Loop Deep Dive

Why the Event Loop Matters Node.js runs JavaScript on a single thread, yet…

MongoDB with Mongoose — Patterns and Pitfalls

MongoDB with Mongoose — Patterns and Pitfalls

Schema Design Philosophy MongoDB schema design is fundamentally different from…

Latest Posts

How to Stay Valuable as a Software Engineer in the AI Era: A Practical Playbook

How to Stay Valuable as a Software Engineer in the AI Era: A Practical Playbook

In the previous article I argued that AI is not killing software engineering…

How to Review AI-Generated Code: A Checklist for Catching What the Model Missed

How to Review AI-Generated Code: A Checklist for Catching What the Model Missed

A few months back I approved a PR that was almost entirely AI-generated. It…

How to Debug AI-Generated Code: Understand First, Fix Last

How to Debug AI-Generated Code: Understand First, Fix Last

In the last article I wrote about reviewing AI code before it goes in. This one…

The Future of Software Engineering: What AI Changes, What Stays, and Where the Jobs Go

The Future of Software Engineering: What AI Changes, What Stays, and Where the Jobs Go

Every few weeks somebody sends me a scary headline — “AI will replace all…

The Transactional Outbox Pattern: Never Lose an Event Again

The Transactional Outbox Pattern: Never Lose an Event Again

This is one of those patterns that looks like nothing until you get burnt once…

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Here is a question I like to ask in interviews: your bank account shows a…