Part II · Chapter 2 · One Service · lesson 4
It runs fine on your laptop and badly in production
12 min
Builds on It works for you and falls over for a thousand people
Part II · Chapter 2 · One Service · lesson 4
12 min
Builds on It works for you and falls over for a thousand people
The weekly report endpoint returns in well under a tenth of a second on your laptop. In production the same code, same commit, takes about two seconds at the median and about nine at the ninety-fifth percentile.
Nothing is on fire. Nobody can reproduce it. Someone has already suggested a bigger database instance.
Before any of that: which difference between the two machines is doing this?
Predict
Six differences between a laptop and production, in the order they bite.
Row count. Hundreds locally, tens of millions in production. A plan that was free becomes the response.
Concurrency. Your laptop has one user. Pools, worker slots and locks are all invisible at one.
Network distance. Loopback locally, a cross-zone hop in production. Each round trip costs about a millisecond, so a chatty handler multiplies.
Cache temperature. Your whole dev dataset fits in memory. In production the working set may not, and then reads hit disk.
Resource limits. Container CPU quotas, memory limits and burstable credit balances. Your laptop has none of them, and a quota throttles in a way that looks like slowness rather than load.
Neighbours. Other containers on the node, other tenants, a sidecar proxy.
Only the fifth and sixth are about the hardware being smaller.
What would you do
A Python service, one endpoint, /reports/weekly. Locally it is instant. In production the median is about two seconds and the ninety-fifth percentile is about nine. The endpoint is called from a dashboard that perhaps forty people have open at once during the working day.
Production is a container with two vCPUs and a managed Postgres in a different availability zone. The table the report reads has roughly forty million rows; your development copy has about three hundred. There is no tracing, and the only dashboards are CPU and memory, both of which look unremarkable.
One query, before anything else. pg_stat_statements ordered by
total_exec_time descending gives you the statements your production database
actually spends its life on, ranked, with call counts and mean times. It ships
with Postgres, needs the extension created and the library preloaded, and it
answers "which query" and "how many times per request" in one look. If a
statement you expected once per request has a call count forty times the
request count, you have found an N+1 without instrumenting anything.
Then the plan. EXPLAIN (ANALYZE, BUFFERS) on that statement, in
production or against the restored snapshot. You are looking for a Seq Scan
with a large "Rows Removed by Filter", or an index that exists and is not
chosen. auto_explain with a minimum duration logs the real plans of real slow
requests, which beats reasoning about the plan of a query you typed by hand.
Rule out throttling with a counter, not a feeling. In the container's
cgroup, nr_throttled in cpu.stat is non-zero or it is not; in Prometheus,
container_cpu_cfs_throttled_periods_total. This takes a minute and removes
the one cause whose symptom is indistinguishable from every other cause.
Count the queries locally. In Django, the debug toolbar or
len(connection.queries); in SQLAlchemy, an after_cursor_execute event with
a counter. Twelve queries per request on a loopback interface is invisible and
across a zone boundary it is a measurable fraction of the response.
The reason this order works is that each step is cheap and each one eliminates a class. The expensive move — resize, rewrite, add a cache — comes after the class is known, not instead of knowing it.
Check
The condition under which all of the above inverts: when the production machine is genuinely smaller than the laptop.
A current laptop with ten or so performance cores and local flash storage reading at several gigabytes a second is strictly faster than a container with two vCPUs on network-attached storage with a provisioned IOPS ceiling. When that is the situation, you are not looking for a difference in behaviour, you are looking at a difference in hardware, and the six-item list is the wrong list.
Three things to check, in this order: the instance type and whether it is a burstable class, the credit balance if it is, and the storage IOPS and throughput limits against what the workload asks for. A burstable instance that has exhausted its credits is throttled to a fraction of a core and no profiler will ever tell you that.
And then the answer is often to buy a larger machine, which is the cheapest fix in this entire subject and the one engineers are most reluctant to reach for. If the service needs an hour of work and fifty dollars a month, prefer the fifty dollars.