Performance

Why is my app slow? Find the cause before you rewrite anything

A mechanical stopwatch stopped part-way through a count
Photo by linper on Unsplash

"The app is slow" is the most common brief we get, and it is almost never accompanied by a number. The team has a theory — usually the database, sometimes the framework, occasionally the language — and a proposal to rewrite the part they already disliked.

The rewrite is the most expensive possible response to a problem nobody has measured. It is also the one most likely to reproduce the bottleneck in new syntax, because the cause was in the access pattern rather than the code around it. This is the order we actually look in.

Measure the thing users feel, not the average

An average response time is close to useless. If 95% of requests take 80ms and 5% take 9 seconds, your average looks healthy and one user in twenty is furious. Averages hide exactly the population you are losing.

Look at percentiles — p50, p95, p99 — and look at them per endpoint rather than across the whole app. A slow p99 on a login route is an emergency. The same p99 on a quarterly export is Tuesday.

  • p50 tells you what a typical request costs. It moves when something systemic changes.
  • p95 is where most complaints live. This is the number to hold yourself to.
  • p99 and above is where you find lock contention, cold caches, retries and the one customer with 40,000 rows.

The four places request time actually goes

Nearly every slow request we have profiled has its time sitting in one of four places. Knowing which one changes the entire response.

Where the time goesWhat it looks likeWhat it usually is
The databaseOne endpoint slow, worsens with data volumeA missing index or an N+1 query
Something you callSlow and erratic, unrelated to your own loadA third-party API with no timeout
Work on the request pathSlow in proportion to what the user uploadedA job that should not be synchronous
The browserServer timings fine, users still complainBundle size, render blocking, layout shift

The fourth row is the one engineering teams miss most often, because their own dashboards are green. If your server p95 is 90ms and users still say the app is slow, they are not wrong — you are measuring a different thing from the one they are experiencing.

A tablet on a desk showing a line chart climbing
A single chart of p95 per endpoint, kept honest over weeks, is worth more than a week of profiling with no baseline to compare against.Photo by jakubzerdzicki on Unsplash

Start with the database, because it usually is the database

In a typical product application, the great majority of unexpected latency is query time. Two causes dominate, and both are cheap to fix once you have seen them.

A missing index. Run the query with EXPLAIN ANALYZE. If the plan contains a sequential scan over a large table, you have your answer. Note that this is the same diagnosis we describe for vector search in pgvector versus Pinecone — "the database does not scale" is very often "the index is missing".

An N+1 query. One query fetches 50 rows, then the ORM quietly issues 50 more to load a relation for each. The endpoint is fine with test data and collapses with real data. It is invisible in code review and obvious the moment you count queries per request, which is one line of middleware in most frameworks.

  1. Log the query count and total query time for every request, in development at minimum.
  2. Sort endpoints by query count. Anything issuing dozens of queries for one response is an N+1 until proven otherwise.
  3. Run EXPLAIN ANALYZE on the slowest statement, not the one you suspect.
  4. Add the index, re-measure, and keep the before and after. PostgreSQL's indexing documentation covers which type to reach for.

Then look at what you are waiting on

If your own database is fast and the endpoint is still slow, you are probably waiting on somebody else's — a payment provider, a shipping API, a model vendor. Three failures are near-universal here:

  • No timeout. A default HTTP client will often wait far longer than your user will. Every outbound call needs an explicit timeout chosen by you, not inherited from a library.
  • Serial calls that could be parallel. Three independent 400ms calls awaited one after another cost 1.2 seconds. Run concurrently they cost 400ms. This is frequently the single largest win available.
  • No circuit breaker. When a dependency degrades, every request queues behind it and takes your app down with it. Failing fast keeps the rest of the product alive.

Then ask what is on the request path that should not be

Generating a PDF, resizing images, sending email, calling a model, reconciling an order — none of these need to happen before the user gets a response. They need to happen reliably, which is a different requirement, and it is what a queue is for.

Moving work off the request path is usually a larger and more durable win than optimising the work itself. BullMQ versus RabbitMQ versus Kafka covers choosing the broker; the pattern matters more than the product. If most of your endpoints are doing work the user is not waiting for, you are past tuning and into scale and re-architecture.

The half of the problem that is not on your server

Users experience the browser, not your API. Google's Core Web Vitals are a reasonable proxy for what they feel, and two of them catch most real complaints: Largest Contentful Paint for how long the page looks empty, and Interaction to Next Paint for how long it feels stuck after a tap.

Field data beats lab data here. A Lighthouse score from your laptop on office wifi tells you very little about a customer on a four-year-old Android phone. Use real-user monitoring if you have it, and if you do not, test on a throttled connection before concluding the front end is fine.

Instrument it so the next one is not a mystery

Every hour spent profiling without instrumentation has to be spent again next quarter. OpenTelemetry is the vendor-neutral standard for traces, metrics and logs, and a distributed trace answers in one screen the question that otherwise costs a week: which span consumed the time.

Brendan Gregg's USE method — for every resource, check utilisation, saturation and errors — is still the fastest way to work through infrastructure systematically rather than by hunch. The goal is that the next incident starts with a chart instead of a theory.

What not to do

  • Do not add a cache over an unmeasured problem. Caching a wrong or slow query gives you a fast wrong answer and an invalidation bug to debug later.
  • Do not scale the servers first. More instances of something that blocks on one database do not help, and the bill arrives monthly.
  • Do not rewrite in a faster language. If the time is in query patterns or network waits, the language was never the constraint. Why MVPs break at scale covers what usually is.
  • Do not optimise what you did not measure. The bottleneck is regularly somewhere nobody suspected, which is the entire reason to profile.

What we do

A performance engagement under research, debug and analyze starts by instrumenting the system and establishing a baseline, because without one there is no way to prove a fix worked. Then we profile, find the actual bottleneck, fix it, and hand back the before-and-after numbers along with the instrumentation, so the next question is answerable without us.

Most of these engagements end with a smaller change than the client expected. That is the normal outcome of measuring first, and it is the cheapest result available to you.

Frequently asked questions

Why is my web app slow all of a sudden?

A sudden change usually has a specific cause: a data volume that crossed a threshold and turned an unindexed query into a sequential scan, a dependency that started timing out, or a deploy that added work to the request path. Compare p95 per endpoint before and after the change rather than looking at an overall average, which will hide it.

How do I find the bottleneck in my application?

Measure per-endpoint percentiles first, then check four places in order: database query time, outbound calls you are waiting on, work on the request path that should be a background job, and the browser. Run EXPLAIN ANALYZE on the slowest query and count queries per request to catch N+1 patterns. Do not optimise anything you have not measured.

Is it faster to rewrite the app or optimise it?

Optimise, almost always. Most latency lives in access patterns — missing indexes, N+1 queries, serial network calls, synchronous work that should be queued — and a rewrite reproduces those in new syntax while costing months. A rewrite is justified when the architecture itself prevents the fix, not when the current code is disliked.

What is a good response time for a web application?

As a working target, p95 under 300ms for interactive endpoints and under 1 second for anything heavier. On the browser side, Google's Core Web Vitals thresholds are 2.5 seconds for Largest Contentful Paint and 200ms for Interaction to Next Paint. Measure per endpoint, because one slow export skews a whole-app figure.

Why do my users say the app is slow when our monitoring is green?

Because server monitoring measures a different thing from what users experience. If your API p95 is healthy, the time is going to the browser — bundle size, render-blocking resources, slow interactions — or to a device and network far weaker than your own. Use field data from real users rather than a Lighthouse run on a fast laptop.

References

  1. EXPLAINPostgreSQL Documentation
  2. IndexesPostgreSQL Documentation
  3. Web Vitalsweb.dev
  4. Interaction to Next Paint (INP)web.dev
  5. OpenTelemetry documentationOpenTelemetry
  6. The USE MethodBrendan Gregg

Keep reading

An aerial view of a motorway backed up with traffic
Scale

Why MVPs Break Under Real Load

Success is the failure mode. The shortcuts that got you to launch are exactly the ones that break when launch works.

Two workers watching items move along an automated line in a warehouse
Architecture

BullMQ vs RabbitMQ vs Kafka

Most teams asking this question do not have a queue problem yet — they have work happening inside the request. That changes which answer is right.

An open filing cabinet drawer packed with index cards
Architecture

Inheriting an undocumented codebase

The instinct is to rewrite it. The first two weeks should be spent making it legible instead — and the database will tell you more than the code does.

Let's put it into production.

Book a 30-minute call — you'll walk away with a scope, a timeline and a fixed price.

Book a call