Your endpoint takes seconds
The situation
GET /orders returned in 80ms for months. The table grew to a few hundred thousand
rows and now the same endpoint takes 3–5 seconds, sometimes timing out. Nothing in the
code changed. Load isn’t even high — a single request is slow all by itself.
Diagnosis: why it happens
Endpoint latency ≈ (number of queries × round-trip cost) + (rows each query touches). Data growth blows up one of those factors. The usual suspects, in order of frequency:
1. N+1 queries. The ORM lazily loads relations:
const orders = await Order.findAll({ limit: 100 }) // 1 queryfor (const order of orders) { const user = await order.getUser() // +100 queries}101 round trips × ~1–3ms each = hundreds of ms before any query is even slow. It’s the lookup-in-a-loop bug, crossing the network per iteration.
2. Missing index. WHERE user_id = 42 on 500k rows without an index on user_id
is a sequential scan — the DB reads the whole table per request (see
fundamentals). Grows linearly forever.
3. OFFSET pagination. OFFSET 100000 LIMIT 20 walks and discards 100k rows. Page
5000 costs 5000× page 1.
4. Over-fetching. SELECT * dragging huge JSON/blob columns, or returning 10,000
rows so the client can show 20.
Don’t guess — look. Enable query logging for one request (or use your APM): the
number of queries diagnoses N+1 instantly; EXPLAIN ANALYZE on the slowest query
shows Seq Scan vs Index Scan.
Recommendation: collapse the queries, then index what remains
Fix the N+1 — fetch relations in bulk (every ORM has this):
Before — 101 queries:
const orders = await Order.findAll({ limit: 100 })for (const order of orders) order.user = await order.getUser()After — 1–2 queries:
const orders = await Order.findAll({ limit: 100, include: [User], // JOIN, or one WHERE id IN (…) batch query})Watch the round trips — each bar is one query crossing the network:
Index the filter/sort columns the remaining queries actually use:
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;-- Seq Scan on orders (actual time=0.01..312.44 rows=95) ← the problem
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);-- → Index Scan using idx_orders_user_created (actual time=0.04..0.31) ← 1000× lessReplace deep OFFSET with keyset pagination:
-- before: OFFSET grows linearlySELECT * FROM orders ORDER BY id DESC OFFSET 100000 LIMIT 20;-- after: constant cost at any depth ("give me what comes after the last id I saw")SELECT * FROM orders WHERE id < $lastSeenId ORDER BY id DESC LIMIT 20;When does it matter? Tables under ~10k rows: seq scans are single-digit ms; indexes barely show. An admin endpoint used twice a day can stay lazy. The thresholds that demand action: latency growing with data size (that’s a scan), query counts growing with response size (that’s N+1), and any endpoint on a user path over ~500ms.
Discarded alternatives (and when they ARE the right call)
Caching the response
- What it solves: repeated identical reads stop hitting the DB at all — great latencies for the cache-hit crowd.
- Why it’s not the first option here: it papers over the broken query. Cache misses still take 3s, invalidation is now your problem, and the unfixed query will resurface in every uncached path.
- When it IS the right call: after the query is sane, when the same result is read far more often than it changes → You read the same data constantly.
A read replica
- What it solves: read volume — many concurrent readers spread across copies.
- Why it’s not the first option here: this endpoint is slow alone. A replica runs the same bad plan at the same speed; you’d have two servers doing seq scans.
- When it IS the right call: queries are already efficient and the primary is saturated by read concurrency → Traffic is growing.
Denormalizing (precomputed columns/tables)
- What it solves: replaces expensive joins/aggregations at read time with a ready-made answer maintained at write time.
- Why it’s not the first option here: it trades read cost for write complexity and consistency risk — machinery you don’t need when an index turns the query sub-ms anyway.
- When it IS the right call: genuinely expensive aggregations over large data read frequently (dashboard counters, leaderboards) where indexes can’t help further — often as a materialized view first.
A bigger server
- What it solves: more RAM keeps more of the table in cache; faster disks scan quicker. Zero code changes.
- Why it’s not the first option here: a seq scan is O(n) on any hardware — doubling the server buys one more doubling of data. You’re renting time, expensively.
- When it IS the right call: as a stopgap during a fire, or when the working set genuinely outgrows RAM after queries are efficient (Traffic is growing).
How to measure before and after
- Endpoint latency:
curl -w "%{time_total}" …or your APM’s p50/p95 — the number the user feels. - Query count per request: ORM query logging on for one request. 100+ queries for one list = N+1, found.
- Per-query:
EXPLAIN ANALYZE <query>— look forSeq Scanon big tables, estimated vs actual row mismatches, and the actual-time totals. In Postgres,pg_stat_statementsranks queries by total time across all traffic — start at the top.
Re-measure after each fix; keep the EXPLAIN output in the PR description.
Signals you need something else
- Fast queries, but the same ones run thousands of times per minute → You read the same data constantly.
- The endpoint is slow because it does heavy work inline (PDF, email, resize) → Heavy jobs block requests.
- Everything is indexed and single requests are fast, but the server melts under concurrency → Traffic is growing.
- The response is fast but huge, and the client processes it slowly → Crossing two lists is slow.
Related resources
- Backend & Data fundamentals: indexes and query plans
- Postgres — Using EXPLAIN
- Use The Index, Luke — Paging: why keyset beats OFFSET, in depth