When a REST API Is Slow — the Order for Finding the Bottleneck
“The API is slow” is the report backend developers hear most often, and the vaguest bug report there is. The slow part could be the database, serialization, an external API, or the network. This post narrows those candidates by procedure, not instinct. Server and infrastructure diagnosis is the Why Your Server Is Slow series; this is the layer above — the application code’s view.
Step 0 — turn “slow” into numbers #
Start by measuring two things.
- Which endpoint, and by how much — rank endpoints by response time via APM or access-log aggregation. The reported culprit and the real one often differ.
- Percentiles, not averages — a p50 of 100ms with a p99 of 5 seconds means one user in a hundred waits 5 seconds. And that 1% is usually your heaviest-data users — the ones who matter most. Set the target as a percentile too: “p99 < 500ms.”
Step 1 — split the request into segments #
With the endpoint identified, break one request’s time into segments — spans if you have tracing, a few timing logs if you don’t. It usually decomposes like this:
total 1,240ms
├─ middleware/auth 18ms
├─ DB queries (23) 780ms ← here
├─ external APIs (2) 310ms ← and here
├─ serialization (JSON) 95ms
└─ other logic 37msThis decomposition is half the diagnosis: it shows immediately which of the four usual suspects is inflated.
Suspect ① the database — count first, then speed #
If the DB segment dominates, check two things in order. Query count comes first. The “23 queries” above is the signature of N+1: a query per item while iterating a list. Each query is fast, so the slow-query log stays silent — only counting reveals it. The fix is the ORM’s eager loading (select_related/prefetch_related and friends), collapsing them into a few; details in Django Advanced #3.
If the count is sane but queries are slow, it’s individual query speed — the EXPLAIN territory organized in When the Database Slows Down. Why indexes sit at the center of that problem gets its own post next.
Suspect ② external calls — someone else’s time inside yours #
Payments, notifications, search, LLMs — if third-party calls sit in the response path, your API’s latency floor is set by someone else’s service. The checklist, in order:
- Can it leave the response path? Calls whose results aren’t needed immediately (notifications, log shipping) go on a queue; return the response first. The single biggest win.
- Can they run in parallel? Awaiting two independent calls sequentially is hand-building the round-trip multiplication.
- Are timeouts and fallbacks in place? A speed issue and a reliability issue: cut the coupling where their slowness becomes yours, with timeouts and cached-value fallbacks.
Suspect ③ serialization and payloads — slow in proportion to size #
Converting thousands of objects to JSON is CPU work; it slows in direct proportion to payload size. If responses run to megabytes, the question isn’t “how to serialize faster” but “why are we sending all this?” The fixes, in order: pagination (cursor-based for infinite scroll), field selection (summaries for list views), compression (gzip/brotli cuts transfer time). Note that patterns like “fetch everything, trim in the app” inflate the DB segment and the serialization segment at once.
Suspect ④ repeated computation — where caching is the answer #
If the same input produces the same output computed fresh every time (aggregates, rankings, config data), caching is the fix — but it goes last. Papering over N+1 or payload problems with a cache means the original problem returns as a spike whenever the cache goes cold (expiry, deploys, restarts). Fix the structure, then cache what’s still expensive. Why Redis fits this job is a separate post.
Still slow after all that — go down a layer #
If every application segment looks healthy but the total is slow, the problem lives outside the code: worker/connection pool exhaustion (requests queuing before processing), GC pauses, or the server’s own resources. From here, descend into Why Your Server Is Slow #1. For Python servers, looking directly inside the code with a profiler is covered in a separate post.
Summary #
- Start with measurement: endpoint rankings and percentiles (p99) turn “slow” into numbers, then split one request into segments.
- In the DB segment, check query count (N+1) before individual query speed (EXPLAIN).
- Move external calls off the response path (queues), parallelize them, and cut the coupling with timeouts and fallbacks.
- Big payloads inflate serialization and DB at once — pagination and field selection first; cache only after the structure is fixed.
- Healthy application segments with a slow total means going down a layer: worker pools and server resources.