Scenario Debugging
“The UI Is Fast but the API Takes Too Long”: How to Solve It
Follow the request: DNS and TLS, cold starts, N+1 queries, missing indexes, slow calls to other services, big payloads and missing caches. Then the UX fixes while the backend is repaired.
On this page
- Step 1: confirm it is the server, and split the time
- Step 2: the usual suspects
- Scenario: an endpoint got slow after a feature added a relation. What do you suspect?
- Caching: where, what and how to invalidate
- Meanwhile, improve the UX (the frontend half)
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: if the UI is quick but the request takes seconds, the time is spent waiting for the server. Split the request into stages: network, edge (servers close to the user), app code, database, external calls and serialisation (turning data in memory into text such as JSON that can be sent). Find the biggest stage and fix the root cause. After that, use caching and UX tricks for the rest.
This is the opposite of Lesson 01. Here the browser's main thread is idle. The Network tab shows a huge Waiting for server response (TTFB). TTFB means "time to first byte": the time from sending the request until the first byte of the answer arrives. The Network tab is the DevTools panel that lists every request the page makes and how long each one took. As a full-stack engineer, you should be able to trace the problem all the way down.
Step 1: confirm it is the server, and split the time
In the Network tab, read the request's Timing:
- DNS / Connect / SSL is large: the connection setup is slow. Possible reasons are distance, no CDN or no keep-alive. A CDN (content delivery network) is a group of servers around the world that keep copies of your files and answer from the place closest to the user. Keep-alive means reusing one open connection for many requests, so you do not set up a new one each time. DNS turns a site name into an address, and SSL/TLS sets up the encryption. See Browser Contract, Lesson 00.
- Waiting (TTFB) is large: the server, or something behind it, is slow.
- Content Download is large: the payload is too big, or it is not compressed.
Next, split the server's share into smaller parts. Here are ways to get this breakdown:
- A
Server-Timingheader is an HTTP response header, added by your API, that lists how long each server step took. DevTools shows it in the Timing tab. In Express, middleware is a function that runs for every request before your route handler. This one measures the total time and lets handlers time their own steps. In production, send this header only to internal or logged-in users, because it shows how your server works inside:
app.use((req, res, next) => { const t0 = process.hrtime.bigint(); const marks: string[] = []; res.locals.time = async <T>(name: string, fn: () => Promise<T>) => { const s = process.hrtime.bigint(); try { return await fn(); } finally { marks.push(`${name};dur=${Number(process.hrtime.bigint() - s) / 1e6}`); } }; const writeHead = res.writeHead.bind(res); res.writeHead = (...args: any[]) => { marks.push(`total;dur=${Number(process.hrtime.bigint() - t0) / 1e6}`); res.setHeader("Server-Timing", marks.join(", ")); return writeHead(...args); }; next();});// usage: const orders = await res.locals.time("db", () => repo.listOrders(user.id));- Distributed tracing / APM (OpenTelemetry, Datadog, X-Ray, New Relic). Tracing records the path of one request across all your services. APM means "application performance monitoring": tools that collect these timings. A span is one timed step, and a span waterfall is a chart of the spans. It shows the handler time, each SQL query and each outgoing HTTP call.
- Structured logs with durations for each request. A structured log is a log line written as data (for example JSON with fields like
requestIdanddurationMs) so you can search it. See Backend, Lesson 05.
A request that takes 3 seconds usually breaks down like this. The biggest bar shows where to dig:
total 3,050 ms├─ edge/network + TLS 60 ms├─ auth + validation 15 ms├─ DB: 118 queries 2,300 ms ← N+1├─ external pricing API 550 ms ← serial call├─ JSON serialisation 90 ms└─ response (1.8 MB) 35 msStep 2: the usual suspects
1. N+1 queries
N+1 means the code runs one query per row. First one query gets the list (that is the "1"), then one more query runs for each of the N items in the list (that is the "N"). See Backend, Lesson 08.
How to see it: the trace shows tens or hundreds of almost identical queries. The latency grows with the size of the list.
Fix: load the related data together. Options: "eager load" with include/select (ORM options that fetch the related rows together instead of one by one; an ORM is a library that maps database tables to objects) or a join (one query that combines rows from two tables), a single IN query (WHERE id IN (1, 2, 3), which fetches many rows at once), or DataLoader (a library that collects many small lookups made at the same time and sends them as one).
2. Missing or unusable indexes
An index is an extra data structure that the database keeps beside a table so it can find rows fast. It is like the index at the back of a book: you look up the word and jump to the page. If you filter or sort on a column with no index, the database does a sequential scan (it reads every row). This gets slower as the table grows.
A foreign key is a column that points to a row in another table (for example order_items.order_id). A foreign key without an index also makes joins and cascades (automatic deletes or updates of related rows) slow. PostgreSQL does not create these indexes for you.
How to see it: EXPLAIN (ANALYZE, BUFFERS) (a PostgreSQL command that runs the query and prints the steps the database took) shows Seq Scan with a large Rows Removed by Filter, or a Sort that uses the disk. See Backend, Lesson 07.
Fix: add the right index (often a composite one: one index over several columns together). Also make sure queries can use it. For example, do not wrap the column in a function. On a live PostgreSQL table, create it with CREATE INDEX CONCURRENTLY. A plain CREATE INDEX blocks writes to the table while it builds.
3. Unbounded queries and no pagination
"Unbounded" means there is no limit. An example is SELECT * FROM events returning 400,000 rows for a "recent activity" widget.
Fix:
- Always use
LIMIT(an SQL clause that caps how many rows are returned). - Paginate with a cursor (the client sends the last item it saw, and the server returns the items after it).
- Select only the columns you need.
- Set a maximum page size.
4. Slow external calls, made one after another
// ❌ Serial: 3 x 400 ms = 1.2 sconst user = await getUser(id);const prices = await pricingApi.get(sku);const stock = await inventoryApi.get(sku);// ✅ Independent calls in parallel: ~400 ms (the slowest). Promise.all starts all calls at once and waits for all of them.// It rejects as soon as one call fails. For optional calls, use Promise.allSettled so one failure does not break the page.const [user, prices, stock] = await Promise.all([getUser(id), pricingApi.get(sku), inventoryApi.get(sku)]);More fixes:
- Set timeouts and fail soft. Failing soft means you return the page without the optional widget, instead of waiting forever.
- Cache third-party data that does not change often.
- Move calls that are not critical off the request path. Put them in a queue (a list of jobs waiting to be done), process them in a background worker (a separate process that takes jobs from the queue), and return
202(the HTTP status code for "accepted, still working"). See Practical Design Problems, Lesson 07. - Use a circuit breaker. It works like a fuse in your house. When a dependency keeps failing, the breaker stops calling it for a while. Then every request does not have to wait for the timeout.
5. No caching of expensive, repeated work
The same result is calculated again for every request. See the caching section below.
6. Cold starts and cold caches
A cold start is the slow first run after a quiet period, when the platform must start a new instance of your code. Serverless functions (small pieces of code that a cloud provider runs for you only when a request arrives) and containers that scale down to zero need this extra start-up time on the first request after being idle. A cold cache is a cache that does not hold the data yet.
How to see it: the slow requests are the first ones after a quiet time, and the trace shows start-up time.
Fixes:
- Keep instances warm, or use provisioned concurrency (instances that are always ready).
- Reduce the bundle size and the start-up work.
- Lazy-load heavy modules (load them only when first needed).
- Reuse connections between invocations.
- Move paths where latency is critical to services that are always on.
7. Wrong region or far-away dependencies
A region is the place (for example a city or country) where a cloud provider's data center is. For example, the app server is in Virginia, the database is in Mumbai, and the user is in Ahmedabad. Every query crosses an ocean. A round trip is one request and its answer, and an N+1 multiplies it many times.
Fix: put the app and the database in the same place. Use read replicas (extra read-only copies of the database) or edge caching near the users. Make fewer round trips.
8. Connection pool exhaustion and lock contention
A connection pool is a limited set of open database connections that requests share, so each request does not have to open its own. When all connections are in use, new requests queue and wait for one. This wait shows up as app time, not as query time.
Causes: long transactions, slow queries, or too many app instances for the database's connection limit. See Backend, Lesson 08.
How to see it: pool wait metrics, "too many connections" errors, and queries that are fast alone but slow under load.
Fixes:
- Make transactions shorter.
- Add a pooler (a separate tool that shares a few database connections between many app instances, for example PgBouncer). Do not simply raise the pool size: a bigger pool can overload the database.
- Tune the pool sizes.
- Find and fix the slow queries that hold connections.
Also check for row or table locks (a lock stops other queries from changing the same data). Use pg_locks and pg_stat_activity.
9. Event-loop blocking in the Node process
Node.js runs your JavaScript on one thread. The event loop is the mechanism on that thread that takes one waiting task at a time and runs it. While one task runs, no other task can start. If you run heavy synchronous work (large JSON, report generation, synchronous crypto), all other requests have to wait. Event-loop lag is how late the loop is in starting its next task. See Backend, Lesson 10.
How to see it: high event-loop lag, and latency spikes on endpoints that have nothing to do with the heavy one.
Fix: move the work to workers or queues, stream the data, or make the code faster.
10. Heavy payloads and serialisation
A payload is the data in the body of a response. A 2 MB response takes time to build, serialise, compress and download.
Fix:
- Return only the fields you need.
- Paginate.
- Compress with gzip or Brotli (two compression formats that make text responses much smaller).
- Use efficient formats where they fit.
- Do not return whole ORM objects with every relation (an ORM object is a database row wrapped in a code object).
11. Chatty APIs
A "chatty" API needs many small calls. For example, the page calls 20 endpoints to render. Each one is "fast", but the total latency and connection cost add up.
Fix: make one endpoint that returns everything the page needs (an aggregate endpoint, or a BFF: "backend for frontend", a server layer built for one UI that gathers the data it needs). You can also use GraphQL (a query language where the client asks for exactly the fields it wants in one request), or load the data on the server.
Scenario: an endpoint got slow after a feature added a relation. What do you suspect?
Here are my guesses, from most likely to least likely. Each has a fast check:
- N+1: the new relation is loaded once per row. Check: count the queries in the trace. Compare a small account with a large one.
- Missing index on the new foreign key or the new filter. Check: run
EXPLAIN (ANALYZE, BUFFERS)on the new query and look for Seq Scan. - Join explosion / over-fetching: a join explosion is when a join returns far more rows than expected. An
includeof a to-many relation (one parent has many children) multiplies the rows (orders × items × tags). Over-fetching means loading more data than the screen needs. With the join strategy, the parent data repeats for every child. Check: row counts and response size. Switch to a separate query, or paginate the child relation. - Unbounded to-many include: it loads all the children of every parent. Check: the response size grows. Add
takeor pagination. - Serial external call added by the feature. Check: the trace spans.
Fix the cause you confirmed. Add a regression test ("at most N queries for this endpoint"). Compare p95 (the time that 95 percent of requests beat) before and after. A regression test is a test that stops an old problem from coming back.
Caching: where, what and how to invalidate
A cache keeps a copy of a result so you do not need to work it out again. Caching is the last tool, not the first. It hides the slowness instead of removing it, and invalidation (deciding when the copy is too old) is hard. When the work is really expensive and repeated, cache in layers. The word "stale" means old and possibly out of date:
| Layer | Example | Good for | Watch out for |
|---|---|---|---|
| Browser / HTTP cache | Cache-Control (a header that says how long a response may be reused), ETag (a version tag that lets the browser ask "has this changed?") (Browser Contract, Lesson 05) | Static and public data | Personal data must be private/no-store |
| CDN / edge | s-maxage (how long shared caches like a CDN may keep a response), stale-while-revalidate (serve the old copy while a new one loads) | Public responses that are read a lot | Never cache responses that are specific to one user |
| Shared cache (Redis/Memcached): in-memory data stores that all your servers use | Cache-aside for query results, sessions, rate limits | Expensive aggregates, hot reads across instances | Invalidation, stampedes, serialisation cost |
| In-process memory | An LRU map (a map that drops the least recently used entry when it is full) | Tiny, very hot data that rarely changes | Separate copies in each instance, memory growth, inconsistency |
| Database | Materialised views (saved results of a query that you refresh now and then), summary tables | Heavy reports | How and when to refresh |
Cache-aside pattern. In this pattern the application itself manages the cache. First look in the cache. If the value is not there, do the work, then save the result in the cache:
async function getProductPage(id: string) { const key = `product:v2:${id}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached); const data = await buildProductPage(id); // the expensive work await redis.set(key, JSON.stringify(data), "EX", 300); // ioredis syntax; TTL of 300 s as a safety net return data;}// On update: await redis.del(`product:v2:${id}`) ← explicit invalidation when the source changesHow to invalidate. Choose on purpose:
- TTL only. TTL means "time to live": the value expires after some time. This is the simplest way. You accept that the data can be a little old.
- Explicit invalidation on write. Delete or update the key. Do this ideally from the code that changes the data, or through events.
- Versioned keys (
v2, or a version number stored for each entity). When the version changes, the old keys are never read again, so you invalidate a whole group at once. - Stale-while-revalidate. Serve the old value while you refresh it in the background.
Cache stampede. This is the problem where many requests rebuild the same value at once. When a popular key expires, thousands of requests miss the cache at the same moment. They all hit the database together. To reduce this:
- Request coalescing, also called "single flight": one request recomputes the value and the others wait.
- A short lock.
- Jittered TTLs: add a small random time to each TTL so keys do not all expire together.
- Serve the stale value while you refresh.
Do not cache:
- Responses that are very personal (unless the key includes the user).
- Data that must be strongly consistent (balances, stock at checkout).
- Error responses, except for a few seconds at most.
Meanwhile, improve the UX (the frontend half)
Sometimes the backend cannot be fixed this week. Then make the wait feel shorter, and keep the data correct:
- Skeletons and incremental rendering. A skeleton is a grey placeholder shaped like the content that will appear. Show the layout at once and fill in the data when it arrives. Use Suspense and streaming (React shows a fallback for a slow part, and the server sends that part later) so slow sections do not block fast ones.
- Optimistic UI. Update the screen at once, as if the change already worked, when the user changes something. Roll back if the server reports an error.
- Stale-while-revalidate on the client (React Query and SWR are libraries that do this). Show the last known data at once and refresh it quietly.
- Prefetch requests that will probably come next (on hover, or route prefetch). Also cache responses on the client.
- Paginate or load step by step. Load the part above the fold (the visible part) first.
- Show progress for long operations. Return
202, then poll or push the status. Never freeze the page on a spinner for 30 seconds. - Timeouts, retries with backoff and clear error states, so users are not left waiting. A retry is trying the request again. Backoff means you wait longer after each failed try (for example 1 s, 2 s, 4 s).
- Make fewer requests (batch them, remove duplicates) so the slow endpoint is called less often.
Common mistakes
- Adding Redis before finding out why the query is slow.
- Fixing the average latency while p99 (the time that 99 percent of requests beat, so it shows the slowest 1 percent) is still terrible.
- Optimising the handler when the time is in the database, or the other way round.
- Serial awaits for calls that are independent.
- No timeouts on outbound calls.
- Testing with tiny seed data, so N+1 and missing indexes never show up.
- Caching personal data in shared caches.
- Ignoring cold starts and pool waits, because they do not show up as query time.
Interview questions
How do you find which part of a 3 s request is slow?
First I check in the Network tab that TTFB takes most of the time. Then I split the server's share with a Server-Timing header, distributed tracing, or structured logs with durations. The span waterfall shows the handler time, each query and each outbound call. I pick the biggest bar and look at it closely. For queries I use EXPLAIN. For N+1 I count the queries. For external calls I check the timings. I fix that part before anything else.
Scenario: an endpoint got slow after a feature added a relation. What do you suspect?
I suspect an N+1 on the new relation, or a missing index on the new foreign key or filter. It could also be a join explosion or an unbounded to-many include, which make the rows and payload bigger. Or the feature may have added a serial external call. I check the query count in the trace, run EXPLAIN ANALYZE, and look at the response size and span timings. Then I fix the problem, add a query-count test, and compare p95.
Where would you add caching, and how do you invalidate it?
I add caching after I fix the root cause. I cache data that is expensive, repeated and mostly read, in layers. I use HTTP/CDN for public responses, Redis cache-aside for aggregates, and in-process memory only for tiny hot data. I use a TTL as a safety net, and I also invalidate explicitly when data is written (or use versioned keys). To avoid stampedes, I use request coalescing, jitter and stale-while-revalidate. I do not put personal or strongly consistent data in shared caches.
What can the frontend do while the backend is being fixed?
I can show skeletons and use streaming, so the page is useful at once. I can use stale-while-revalidate and caching on the client, and optimistic updates for changes. I can prefetch, paginate and load step by step. For long operations I use a 202 response and poll for the status. I also add clear timeouts, retries and error states. These things improve how fast the app feels, but they do not hide the real problem.
Why can a request be slow even when every database query is fast?
The time may go to waiting for a connection in the pool, event-loop blocking, serial external calls, serialising a huge payload, a cold start, or regions that are far apart. None of these show up as slow queries. A trace shows where the real clock time goes.
How do you prevent a cache stampede?
I make concurrent misses share the work: only one request recomputes while the others wait or get the stale value. I use short locks. I add random jitter to TTLs so keys do not expire together. I refresh popular keys before they expire, and I serve stale content while I revalidate.
How do you handle a slow third-party API you depend on?
I set strict timeouts and call it in parallel with other work. I cache results that are stable, and move calls that are not critical to background jobs. I retry with backoff only for idempotent requests (requests that give the same result if you send them twice, so a repeat is safe). I wrap the API in a circuit breaker with a fallback. I also watch its latency and error rate separately.
Key takeaways
- Break TTFB into stages (edge, app, DB, external calls, serialisation) with Server-Timing, traces and logs. Fix the biggest bar.
- The usual causes are: N+1, missing indexes, unbounded queries, serial external calls, pool exhaustion, cold starts, event-loop blocking, huge payloads and the wrong region.
- Fix the cause first, then cache on purpose, with a clear plan for invalidation and protection from stampedes.
- Meanwhile, skeletons, streaming, optimistic UI, SWR, prefetch and async jobs keep users unblocked.
- Add a query-count test and a p95 alert so the problem does not come back.
What's next
Lesson 03: Unnecessary Re-renders. Back to the browser. Sometimes "the UI is slow" really means React is doing work it does not need to do.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Cost, Latency and Caching for LLM Features · AI Engineering
- HTTP Caching: Cache-Control, ETags and the CDN in Between · The Browser Contract
- Design a CDN · System Design Case Studies 2
Part of the study guide
Related series