An API that used to respond quickly and now does not is one of the most common problems in backend work, and one of the easiest to waste a day on. The temptation is to start optimizing the code you happen to be looking at. That is guessing.
The nine causes below account for the overwhelming majority of slow APIs, and they are ordered by how often they turn out to be the answer. For each one you get the symptom, the way to confirm it, and the fix. But before any of that, you need a number.
Step Zero: Measure Before You Change Anything
You cannot fix a slowness you have not measured, and you cannot tell whether a change helped without a baseline.
Record percentiles, not averages. An average response time hides the problem. If 95 requests take 40 units of time and 5 take 4,000, the average looks acceptable and 5% of your users are having an awful experience. Track p50, p95, and p99. The gap between p50 and p99 tells you whether you have a slow API or an inconsistent one, and those have different causes.
Time the layers separately. For a single slow endpoint you need four numbers: total request time, time spent in the database, time spent calling other services, and time spent in your own code. Until you know which of those four dominates, every fix is a coin flip.
Most frameworks can log this. A minimal version in any language looks like this:
import time
import logging
start = time.perf_counter()
rows = run_query()
db_ms = (time.perf_counter() - start) * 1000
logging.info("endpoint=%s db_ms=%.1f rows=%d", "/orders", db_ms, len(rows))
Reproduce it in isolation. Call the endpoint directly, without the browser, without the load balancer, and without your frontend:
curl -o /dev/null -s -w "dns:%{time_namelookup} connect:%{time_connect} ttfb:%{time_starttransfer} total:%{time_total}\n" \
https://api.example.com/orders
If ttfb is large and total is barely larger, the server is slow to start answering, which points at causes 1 through 6 below. If ttfb is small and total is much larger, you are shipping too much data, which is cause 4.
Set your own baseline from these measurements. What counts as "slow" depends entirely on the endpoint: a search across millions of rows and a lookup by primary key have nothing in common.
1. N+1 Queries
This is the single most common cause of a slow API, and it is almost always accidental.
The symptom. Response time grows in proportion to the number of items returned. One record is fast, 20 records are noticeably slower, 200 records time out. Database time dominates the request.
Why it happens. Your code fetches a list, then loops over it and fetches something related for each item. One query for the list, plus one query per row, is N+1 queries. Object-relational mappers make this invisible, because the extra queries are triggered by attribute access that looks like ordinary code:
orders = Order.objects.all() # 1 query
for order in orders:
print(order.customer.name) # 1 more query, every iteration
How to confirm it. Count the queries in one request. Every mature framework has a way: Django Debug Toolbar, Hibernate statistics, ActiveRecord query logs, or turning on statement logging in the database for 30 seconds. If a single request fires 40 nearly identical queries differing only in an ID, you have found it.
The fix. Fetch the related data in the same round trip. In Django that is select_related or prefetch_related; in JPA and Hibernate it is a JOIN FETCH or an entity graph; in ActiveRecord it is includes. The query count should drop to a small constant regardless of how many rows come back.
2. Missing Indexes
The symptom. One specific query is slow, consistently, and gets slower as the table grows. Often it was fast when the feature shipped and is slow six months later.
How to confirm it. Ask the database. In PostgreSQL:
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
Read the plan for Seq Scan on a large table. That means the database read every row to find the ones you wanted. Index Scan means it used an index. MySQL uses EXPLAIN ANALYZE similarly, and reports type: ALL for a full scan.
The fix. Add an index covering the columns in your WHERE, JOIN, and ORDER BY clauses. For a query filtering on two columns, one composite index on both usually beats two separate indexes, and column order matters: put the column you filter on for equality first.
Indexes are not free. Each one slows writes and takes disk. Add them because a query plan asked for them, not on principle. Our guide to understanding database indexing covers how the structures work and when a composite index helps.
3. No Caching on Expensive Reads
The symptom. A response that is identical for many users, or identical for the same user for minutes at a time, is recomputed on every request. Load scales linearly with traffic and the database is busy doing the same work repeatedly.
How to confirm it. Look at your query logs and count how many times the same query with the same parameters runs per minute. If a reference table or a dashboard aggregate is being computed thousands of times an hour and changes twice a day, that is the finding.
The fix. Cache at the layer that removes the most work.
In-memory or Redis caching of the computed result, keyed on the inputs, with an explicit expiry.
HTTP caching with
Cache-ControlandETagheaders, so clients and any CDN in front of you stop asking at all.Materialized views for aggregates that are expensive and tolerate being slightly stale.
The hard part of caching is never the reading, it is invalidation: deciding when a cached value is wrong. Start with a short time-based expiry, which is simple and safe, and move to event-based invalidation only when staleness becomes a real problem. Caching strategies covers the patterns and their trade-offs.
4. Oversized Responses
The symptom. Time to first byte is fine, total time is not. The endpoint is slower for users on worse connections, and slower on mobile than on your desk.
How to confirm it. Measure the response body size:
curl -s -o /dev/null -w "size:%{size_download} total:%{time_total}\n" https://api.example.com/orders
If the body is measured in megabytes, the API is not slow, it is fat.
The fix, in order of impact.
Paginate. An endpoint that returns every row will eventually return too many. Use limit-and-offset or, better for large tables, cursor pagination.
Return fewer fields. Most clients use a fraction of what a serialized entity contains. Define an explicit response shape rather than dumping the database row.
Stop nesting everything. Deeply embedded related objects multiply payload size quickly. Return identifiers and let the client ask for what it needs.
Enable compression. Gzip or Brotli on JSON is close to free and often halves transfer size.
5. Blocking Work Inside the Request
The symptom. The endpoint is slow but neither the database nor your own computation explains it. Response time matches the duration of something external, like sending an email or generating a file.
Why it happens. Work that does not need to finish before the client gets an answer is being done anyway: sending a confirmation email, resizing an image, calling a payment provider, writing an audit record to a third-party service.
How to confirm it. Time each external call separately, as in step zero. When one third-party call accounts for most of the request, you have it.
The fix. Move it out of the request path. Accept the request, write the job to a queue, return 202 Accepted with a way to check status, and let a worker do the slow part. If the work genuinely must happen before responding, set an aggressive timeout on it and decide explicitly what happens when it is exceeded, because a call with no timeout is an outage waiting for the other service to have a bad day.
6. Connection Pool Exhaustion
The symptom. The API is fast under light load and falls off a cliff under heavy load. Response times do not degrade gracefully, they jump. Logs contain timeouts waiting for a connection.
Why it happens. Your app holds a fixed pool of database connections. When every connection is checked out, new requests queue for one. The queue wait shows up as latency that has nothing to do with query speed.
How to confirm it. Compare active connections against your configured pool size while under load. In PostgreSQL:
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
Many connections sitting in idle in transaction is the strongest signal: something opened a transaction and did slow work inside it, such as an HTTP call, while holding a connection nobody else can use.
The fix. Keep transactions short and never do network calls inside one. Make sure connections are always returned, which means using your framework's context manager rather than manual open and close. Only then consider raising the pool size, because a larger pool pointed at an overloaded database moves the queue rather than removing it.
7. Chatty Service-to-Service Calls
The symptom. In a microservices system, one endpoint is slow and every service it depends on reports being fast. Latency appears to come from nowhere.
Why it happens. Serving one request requires 8 sequential internal calls, and the total is the sum of all of them plus 8 round trips of network overhead. Each hop is fast, the chain is not. It is the N+1 problem at the service boundary.
How to confirm it. Distributed tracing, with a request ID propagated through every service, is the only reliable way to see this. A trace makes a serial chain of calls immediately obvious as a staircase.
The fix. Call in parallel where calls are independent, since 8 concurrent calls cost roughly the slowest one instead of the sum. Batch where the downstream service supports it. Cache the responses that rarely change. And where two services are always called together for the same data, question the boundary between them.
8. Serialization and Payload Construction
The symptom. Time is spent in your own process, not the database and not the network. Profiling points at serializer or JSON code.
Why it happens. Turning objects into JSON costs real CPU at volume, and it is easy to make worse by accident: validating on the way out as well as in, serializing fields nobody uses, or triggering a lazy database lookup from inside a serializer, which quietly re-creates cause 1.
How to confirm it. Profile one request. Any language profiler that gives you time-per-function will show serialization near the top when this is the cause.
The fix. Serialize only the fields the response declares. Use your language's fastest available JSON library. Do not validate outgoing data you generated yourself. And check whether any field on the way out is triggering a database call.
9. Cold Starts and Warm-Up
The symptom. The first request after a deploy, or after a quiet period, is dramatically slower than the ones that follow. Averages look fine and occasional requests look terrible.
Why it happens. Several things only happen once: a serverless function has to be provisioned, connection pools fill lazily, caches start empty, and just-in-time compilers have not yet optimized the hot paths. On the JVM specifically, a service is measurably slower in its first minutes because the JIT has not finished its work.
How to confirm it. Correlate your slowest requests with deploys and with periods of low traffic. If p99 spikes line up with scale-up events, it is cold starts, not your code.
The fix. Warm up on startup: open the connection pool, prime the caches, and run one representative query before the instance reports itself healthy. Keep a minimum number of instances running rather than scaling to zero for latency-sensitive endpoints. And make your readiness check mean "ready to serve fast", not merely "the process started".
Work Through It in This Order
The order matters, because it moves from most likely and cheapest to check, to least likely and most invasive.
| # | Cause | Confirm with | Typical fix |
|---|---|---|---|
| 0 | Unknown | Percentiles, per-layer timing | A baseline |
| 1 | N+1 queries | Query count per request | Eager loading |
| 2 | Missing index | EXPLAIN ANALYZE | Composite index |
| 3 | No caching | Repeated identical queries | Redis, HTTP cache headers |
| 4 | Oversized response | Response body size | Pagination, fewer fields |
| 5 | Blocking work | Per-call timing | Background queue |
| 6 | Pool exhaustion | Active vs configured connections | Shorter transactions |
| 7 | Chatty services | Distributed trace | Parallel calls, batching |
| 8 | Serialization | Code profiler | Explicit response shape |
| 9 | Cold starts | Correlate with deploys | Warm-up on startup |
The most common mistake is jumping to number 8, because serialization code is the code you can see, when the answer usually turns out to be number 1 or number 2. The second most common is fixing something and never re-measuring, so nobody knows whether it worked.
Practise This on a Real System
Diagnosing latency is a skill that only develops on a system with real data in it, because none of these causes show up on a table with 50 rows. The system design performance metrics guide covers the measurements this article depends on in more detail, and backend interview questions includes the performance and system design questions this material answers directly.
Frequently Asked Questions
Why is my API slow all of a sudden when the code has not changed?
Data volume is the usual answer. A query with no index is fast on a small table and slow on a large one, and the crossover is sudden rather than gradual. Check whether the slow endpoint touches a table that has grown, and run EXPLAIN ANALYZE on its query.
How do I know whether the database or my code is the problem?
Time them separately in one request. Log total request duration and total database duration for the same call. If database time is most of the total, work through causes 1, 2, 3, and 6. If it is a small fraction, look at causes 5, 7, 8, and 9.
What is an N+1 query problem?
It is when code fetches a list with one query, then fires an additional query for each item in that list to load related data. Response time then grows with the number of results. The fix is to load the related data in the same query with eager loading.
Should I add caching or fix my queries first?
Fix the queries. Caching an inefficient query hides it until the cache misses, and the first request after an expiry is then as slow as it ever was. Make the uncached path acceptable, then cache to reduce load.
Why is time to first byte high but total time low?
The server is taking a long time to begin responding, then sending very little. That points at work done before the response starts: queries, external calls, or blocking work in the request path, rather than payload size.
Does adding more indexes always make things faster?
No. Indexes speed up reads that match them and slow down every write to the table, as well as consuming disk and memory. Add an index because a query plan showed a sequential scan you need to remove, not as a general precaution.
How many database queries should one API request make?
There is no universal number, but it should be a small constant that does not change when the response contains more items. If the query count rises with the number of results, that is an N+1 problem regardless of how fast each query is.
Summary
A slow API is a measurement problem before it is an engineering problem. Record p50, p95, and p99 rather than averages, time the database, external calls, and your own code separately, and reproduce the request without the browser in the way.
Then work the list in order. N+1 queries and missing indexes are the answer far more often than anything else, and both are confirmed in minutes with a query count and an EXPLAIN ANALYZE. Caching, payload size, and blocking work in the request path come next. Pool exhaustion, chatty service calls, serialization cost, and cold starts are real but less common, and each has a specific signal that distinguishes it. Fix one thing, measure again, and keep the baseline. Optimizing without a number is just rearranging code.
