Company
About Tomosu
Platform
Platform & Agents Indexes How it works Solutions Pricing
Get Started
MCP Server VS Code — Plugin Installation Scan Your Repo — Guide Integrations · GitHub App Integrations · CodeRabbit MCP FAQ
Free Tools
Governance Impact
Resources
Blogs News Download / Free Trial Book a call →
Production Debugging · API latency

Why an API Is Slow in Production but Fast Locally

Tomosu AI·15 min read·

An API that is slow in production but fast locally is not running different code. It is running the same code against different data, different statistics, a different network, and different concurrency. The endpoint that returns in 40 ms on your laptop and 3 seconds in production is telling you which of those differences matters. This guide shows how to find out which one it is.

Quick answer

An API is usually slow in production but fast locally because production changes the inputs, not the code: tables are thousands of times larger and skewed, the query planner picks a different plan, every database or HTTP round trip costs more, and concurrent requests queue for connections, locks, and CPU. Measure where the time goes before changing anything.

The fastest way to lose a day is to guess. The second fastest is to run the query locally, see that it is fast, and conclude the database is fine. The workflow below starts with a trace of one slow production request and narrows from there.

Why is an API slow only in production?

Request latency is the sum of the work your code does and the time it spends waiting. On a laptop, almost all of it is work: the database is on localhost, tables hold a few hundred rows, and you are the only user. In production the work often takes about the same time, but the waiting grows. The request waits for a connection from the pool, waits on a lock another transaction holds, waits on a network round trip for each of fifty queries, and waits on a dependency that is itself under load.

Some of the work changes too. A query plan that reads 1,000 rows locally can read millions in production, because the table is bigger and because the planner chose a different strategy for it. That is the classic “database query slow in production” case, and it is one of several.

Questions about an API slow in production but fast locally or a query slow in production but not local are common on Stack Overflow, and the answers vary because the causes do. No single setting fixes all of them. The evidence tells you which one you have.

What actually differs between local and production?

Before you look at any code, list the differences between the two environments. Each row below is a known source of “works on my machine” latency.

FactorLocalProductionWhat it can break
Data volumeSeed data, thousands of rowsTens of millions of rows, years of historySequential scans, sorts, and joins that were free become expensive
Data distributionUniform, syntheticSkewed: a few large tenants, old and new rows clusteredRow estimates are wrong for the values that matter
StatisticsFresh after each resetStale after bulk loads or fast growthThe planner picks a plan for a table that no longer exists
IndexesWhatever the migrations createdDrifted: manual indexes, failed or pending migrationsAn index you rely on is missing, invalid, or unused
ConcurrencyOne request at a timeHundreds of requests in flightQueues for pool connections, row locks, and CPU
NetworkDatabase on localhost, sub-millisecondAnother host or zone, around a millisecond or more per tripEach extra query or call adds real latency
Connection setupReused, no TLSDNS lookups, TLS handshakes, proxiesNew connections cost several round trips each
CachesWarm; working set fits in memoryWorking set larger than memory; cold after deploysDisk reads instead of buffer hits
ResourcesAll cores of a laptopContainer CPU and memory limitsCPU throttling pauses request threads
DependenciesMocked or local stubsReal services with their own load and timeoutsA slow dependency becomes your latency

Most production-only slowdowns come from the first five rows. Check them in order.

Why is a query slow in production but not local?

Take a common endpoint: the latest 20 orders for a tenant.

SQL · the endpoint’s query
SELECT id, status, total, created_at
FROM   orders
WHERE  tenant_id = $1
ORDER BY created_at DESC
LIMIT  20;

The table has an index on created_at and another on tenant_id. Locally the table has 1,000 rows. The planner reads the whole table, keeps the top 20 matches in a small in-memory sort, and returns in well under a millisecond:

EXPLAIN (ANALYZE, BUFFERS) · dev, 1,000 rows0.4 ms
Limit  (actual time=0.402..0.407 rows=20 loops=1)
  ->  Sort  (actual time=0.400..0.403 rows=20 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 27kB
        ->  Seq Scan on orders  (actual time=0.011..0.290 rows=212 loops=1)
              Filter: (tenant_id = 42)
              Rows Removed by Filter: 788
Execution Time: 0.431 ms

In production the table has 50 million rows and the planner chooses a different strategy. It walks the created_at index backwards, newest first, and throws away rows from other tenants until it has 20 for tenant 42. The planner expects that to be quick because it assumes tenant 42’s rows are spread evenly over time. They are not. Tenant 42 is an older customer whose recent activity is low, so the scan walks millions of index entries first:

EXPLAIN (ANALYZE, BUFFERS) · production, 50M rows2.9 s
Limit  (actual time=2871.402..2903.118 rows=20 loops=1)
  Buffers: shared hit=112043 read=389210
  ->  Index Scan Backward using orders_created_at_idx on orders
        (actual time=2871.399..2903.101 rows=20 loops=1)
        Filter: (tenant_id = 42)
        Rows Removed by Filter: 4812330
        Buffers: shared hit=112043 read=389210
Planning Time: 0.214 ms
Execution Time: 2903.170 ms
SAME QUERY, SAME INDEXES, DIFFERENT PLAN SELECT ... FROM orders WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20 DEV · 1,000 ROWS PRODUCTION · 50M ROWS, SKEWED Limit returns 20 rows Sort (top-N heapsort) 212 matching rows sorted in memory Seq Scan on orders 1,000 rows read, 788 filtered out Limit stops after 20 matching rows Index Scan Backward orders_created_at_idx, newest first Filter: tenant_id = 42 Rows Removed by Filter: 4,812,330 0.4 ms · fine at this size 2.9 s · tenant 42 rows are old; 4.8M walked Fix: index (tenant_id, created_at DESC) so both environments read only 20 rows
The dev plan and the production plan are both reasonable choices given what the planner believes. Only one of them is right for the data production actually has.

Two things went wrong at once, and neither shows up locally. The data volume made a wasted scan expensive. The data distribution made the planner’s estimate wrong, because it assumes the columns are independent. Rows Removed by Filter is the tell: the plan did millions of rows of work to return 20. The high read count in Buffers shows that most of it came from disk rather than memory.

The fix gives the planner an index that answers the question directly. Build it without blocking writes:

SQL · fixreads 20 rows
-- Serves the filter and the sort together; no filter-and-discard
CREATE INDEX CONCURRENTLY orders_tenant_created_idx
    ON orders (tenant_id, created_at DESC);

-- Expected plan afterwards:
-- Limit -> Index Scan using orders_tenant_created_idx (rows=20)
EXPLAIN ANALYZE runs the statement

EXPLAIN ANALYZE executes the query to measure it. For SELECT that costs one more slow query. For INSERT, UPDATE, or DELETE, wrap it in BEGIN; ... ROLLBACK; or run it on a replica or a restored copy. To capture plans of slow statements as they happen, use the auto_explain module with a log_min_duration threshold.

Missing, invalid, and unused indexes

Before blaming the plan, confirm the index you expect exists in production. Schema drift is common: an index created by hand during a past incident, a migration that failed halfway, or a CREATE INDEX CONCURRENTLY that was interrupted and left an invalid index the planner will not use. Compare the production schema with the migrations rather than trusting that they match.

PostgreSQL · index checks
-- Indexes on the table, and whether each is valid
SELECT c.relname AS index_name, i.indisvalid, pg_get_indexdef(i.indexrelid)
FROM   pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE  i.indrelid = 'orders'::regclass;

-- Is the index actually used?
SELECT indexrelname, idx_scan
FROM   pg_stat_user_indexes
WHERE  relname = 'orders';

Stale statistics, generic plans, and parameter sniffing

The planner does not look at your data when it plans a query. It looks at statistics about your data: row counts, the most common values, histograms, and the number of distinct values. When those statistics are wrong, the plan can be wrong even with the right indexes in place.

Stale statistics

PostgreSQL refreshes statistics through autovacuum’s analyze step, which runs after a fraction of the table has changed. After a bulk import, a backfill, or a period of fast growth, statistics can describe a table that looks nothing like the current one. Locally, you typically reset and reseed the database, so statistics are always fresh.

PostgreSQL · statistics freshness
SELECT relname, n_live_tup, n_mod_since_analyze,
       last_analyze, last_autoanalyze
FROM   pg_stat_user_tables
WHERE  relname = 'orders';

-- If n_mod_since_analyze is large relative to n_live_tup, refresh:
ANALYZE orders;

If the estimated rows in a plan node differ from the actual rows by orders of magnitude, suspect statistics first. For skewed columns, raising the per-column statistics target (ALTER TABLE ... ALTER COLUMN ... SET STATISTICS) gives the planner a more detailed picture. For correlated columns, PostgreSQL’s extended statistics (CREATE STATISTICS) can help with some estimates.

Generic plans and parameter sniffing

Production code usually runs queries as prepared statements with bound parameters. Locally you paste the SQL with a literal value into a console. Those are not the same execution path.

In PostgreSQL, a prepared statement is planned with the actual parameter values (a custom plan) for its first several executions. After that, the server may switch to a generic plan that ignores the specific value if the generic plan’s estimated cost is not much worse. A generic plan built for an average tenant can be a poor plan for your largest tenant. See the PREPARE documentation for the rules and the plan_cache_mode setting.

SQL Server has the mirror-image problem, called parameter sniffing: the plan is compiled for the parameter values of the first execution and cached, so a plan compiled for a small customer is reused for a large one. The symptom is the same: the query is fast when you run it by hand and slow when the application runs it.

PostgreSQL · reproduce the application’s plan
PREPARE latest_orders(bigint) AS
  SELECT id, status, total, created_at FROM orders
  WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 20;

-- Force each mode and compare
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS) EXECUTE latest_orders(42);

SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS) EXECUTE latest_orders(42);

If the generic plan is the slow one, the durable fix is usually an index that makes both plans good. Forcing custom plans for the session or role is a workaround with a planning-time cost on every execution.

Settings differ too

Plans also depend on configuration: work_mem decides whether a sort or hash fits in memory, and cost settings such as random_page_cost change how attractive an index looks. A managed database, a local Docker image, and a production replica can all have different values. Run SHOW for the settings that matter in both environments before comparing plans.

N+1 queries: why round trip latency matters only in production

An N+1 query pattern runs one query to fetch a list and then one query per item to fetch related data. ORMs make this easy to write by accident through lazy loading. Locally it is almost free, for two reasons: the database is on localhost, and your seed user has three orders, not fifty.

Java · Spring Data JPA1 + N queries
List<Order> orders = orderRepo.findTop50ByTenantIdOrderByCreatedAtDesc(tenantId);
for (Order o : orders) {
    // customer is LAZY: each access issues its own SELECT
    views.add(new OrderView(o, o.getCustomer().getName()));
}
Java · fetch the association in the same query1 query
@EntityGraph(attributePaths = {"customer"})
List<Order> findTop50ByTenantIdOrderByCreatedAtDesc(Long tenantId);

The equivalents elsewhere are JOIN FETCH in JPQL, select_related and prefetch_related in Django, includes in Rails, and eager loading options in SQLAlchemy. The same pattern applies to HTTP: calling a pricing service once per line item inside a loop is an N+1 over the network.

SAME CODE, DIFFERENT ROUND TRIP COST LOCAL · DATABASE ON LOCALHOST · RTT ≈ 0.1 MS 51 queries × 0.3 ms ≈ 15 ms PRODUCTION · DATABASE IN ANOTHER ZONE · RTT ≈ 1 MS ≈ 71 ms 51 queries × (1.0 ms network + 0.4 ms execution) network round trip query execution PRODUCTION, BATCHED · JOIN OR IN (...) · 2 QUERIES 2 queries × 1.4 ms ≈ 3 ms latency ≈ (1 + N) × (round trip + execution) N is also larger in production: 50 orders per page, not the 3 in your seed data
Round trip latency is a multiplier. Near zero on localhost, it becomes the largest part of an N+1 request in production. Figures are illustrative.

The numbers above are illustrative, but the shape is not. Latency grows with the number of round trips times the cost of each. Every request also holds a pool connection for the whole loop, which feeds directly into the next section. A trace makes N+1 obvious: dozens of identical short database spans stacked one after another under a single request span.

Contention: why production concurrency changes the answer

Locally you send one request at a time. Nothing queues. In production, a small increase in per-request time can turn into a large increase in latency, because requests start waiting for shared resources.

Connection pool contention

Little’s law gives the average number of connections in use: arrival rate × time each request holds a connection. If an endpoint handles 400 requests per second and holds a connection for 5 ms, about 2 connections are busy on average. If a plan regression pushes the hold time to 50 ms, it needs about 20. With a pool of 10, the extra requests wait in line, and the wait adds to every request, including requests to endpoints that did not change.

CONNECTIONS NEEDED = ARRIVAL RATE × HOLD TIME BEFORE · QUERY HOLDS 5 MS 400/s × 5 ms = 2 connections busy on average Pool of 10: 8 idle Acquire wait ≈ 0 AFTER · QUERY HOLDS 50 MS 400/s × 50 ms = 20 connections needed Pool of 10: 0 idle, demand queues Acquire wait grows until timeout Locally, one request at a time never forms a queue. Production concurrency does.
A tenfold slower query does not add a fixed 45 ms. Once demand exceeds the pool, every request pays the queue as well.

The HikariCP timeout post in this series covers how to tell a slow query from a leak or an undersized pool using pool metrics. For latency debugging, the key metric is connection acquire time. If acquire time is a large share of request time, the endpoint is waiting, not working, and the cause is whatever holds connections longest.

Lock contention

Row and table locks only conflict when two transactions touch the same data at the same time, which never happens on a single-user laptop. A long transaction that updates a hot row (an account balance, a counter, a tenant settings row) makes every other writer to that row wait. A migration that takes a stronger lock on a busy table can stall reads and writes behind it. In PostgreSQL, find who is blocking whom:

PostgreSQL · lock waits
SELECT pid,
       pg_blocking_pids(pid)   AS blocked_by,
       wait_event_type, wait_event,
       now() - query_start     AS waiting_for,
       left(query, 80)         AS query
FROM   pg_stat_activity
WHERE  cardinality(pg_blocking_pids(pid)) > 0;

CPU throttling and resource limits

In Kubernetes, a container CPU limit is enforced by the Linux CFS quota: once the container uses its quota within a scheduling period, its threads are paused until the next period. Average CPU usage can look moderate while individual requests stall in bursts. Garbage collection and JSON serialization of large responses are common triggers. The Kubernetes resource management docs describe how limits are applied. With cAdvisor metrics in Prometheus, check the throttled ratio:

PromQL · share of CPU periods throttled
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total{container="api"}[5m]))
  /
sum by (pod) (rate(container_cpu_cfs_periods_total{container="api"}[5m]))

Memory limits have a related effect: a JVM or Node process sized close to its container limit spends more time in garbage collection, and each pause adds to request latency. The OOMKilled post in this series covers how to tell the two apart.

Network, TLS, cold caches, and downstream dependencies

Not every production-only slowdown is in the database.

A systematic workflow for an API slow only in production

Start with the request, not the query. A distributed trace of one slow request shows where the time went, and that decides which of the causes above to investigate. If you do not have tracing, OpenTelemetry instrumentation for your HTTP server, HTTP client, and database driver gets you most of the picture.

WHERE 1,840 MS WENT IN ONE PRODUCTION REQUEST GET /api/orders pool.acquire SELECT orders LIMIT 20 SELECT customers ×50 POST payments /quote serialize response 1,840 ms 620 ms waiting for a connection 530 ms, plan regression 230 ms, N+1 DNS + TLS, then call: 380 ms 80 ms, CPU throttled 0500 ms1,000 ms1,500 ms Only 530 ms is the query everyone looks at first. The rest is waiting, round trips, and a dependency.
An illustrative trace. Each colored span points to a different cause and a different fix. Tuning the query alone recovers less than a third of the time.

Once you have a trace, the diagnosis is a short sequence of questions. Ask them in order, because the earlier ones are cheaper to answer and more often the cause.

WHICH CAUSE IS IT? FOUR QUESTIONS, IN ORDER Q1 · GAPS IN THE TRACE WATERFALL Is time spent waiting before work starts? Q2 · PG_STAT_STATEMENTS OR SLOW LOG Does one SQL statement dominate DB time? Q3 · SPAN COUNT PER REQUEST Many small queries or calls per request? Q4 · CLIENT SPANS Is a downstream span slow or retrying? Contention Pool, locks, CPU throttling Plan or index EXPLAIN ANALYZE on prod data N+1 or chatty calls Batch, join, or prefetch Slow dependency Timeouts, DNS, TLS, retries YESYESYESYES NONONONO Only the first requests are slow? Cold start: database and page caches, JIT, lazily opened connections. Warm up before taking traffic.
Waiting comes first because it is the cheapest to confirm and it hides every other cause. A request queued for a connection looks slow no matter how fast its query is.

The steps

  1. Confirm the slowdown is server-side and quantify it. Compare server latency percentiles (p50, p95, p99) by endpoint with what clients see. Averages hide the tail where production-only problems live.
  2. Trace one slow request. Split its time into waiting (pool acquire, locks, throttling), database, downstream calls, and your own CPU.
  3. Find the statements that dominate database time. Use pg_stat_statements in PostgreSQL, or the slow query log and Performance Schema in MySQL.
  4. Get the production plan with real parameters. Run EXPLAIN (ANALYZE, BUFFERS) on production or a production-sized copy, using the parameter values from the slow requests, and through a prepared statement if the application uses one.
  5. Check what the planner knows. Compare estimated and actual rows, statistics freshness, index validity, generic versus custom plans, and settings such as work_mem.
  6. Count round trips per request. Look for repeated identical spans, which point to N+1 queries or per-item HTTP calls.
  7. Check contention and the environment. Pool acquire time, lock waits, CPU throttling, dependency latency, and whether slowness is limited to the minutes after a deploy.
  8. Reproduce at production scale, fix, and verify. Reproduce with production-sized data and realistic concurrency, apply the fix for the cause you found, and confirm with the same metrics that showed the problem.
PostgreSQL 13+ · pg_stat_statements
SELECT calls,
       round(total_exec_time)       AS total_ms,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       rows / NULLIF(calls, 0)       AS rows_per_call,
       shared_blks_read,
       left(query, 80)              AS query
FROM   pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT  15;

Sort by total time as well as mean time. A 2-second query that runs twice an hour matters less than a 4 ms query that runs 50 times per request. A very high calls count relative to request volume is the database-side signature of an N+1 pattern.

Reproducing locally

A local repro is worth having, but only if it reproduces the difference. That means production-scale data with production-like skew (a restored, anonymized snapshot is best), statistics refreshed with ANALYZE, the same database settings, the same prepared-statement path, and a load generator that sends concurrent requests. A seed file of 1,000 uniform rows will always say the query is fast.

Fixes, matched to the cause

CauseFixWhat not to do
Missing or wrong indexAdd an index that serves the filter and the sort together; build it concurrently. Remove duplicates that only add write cost.Add single-column indexes on every filtered column and hope.
Stale statistics or skewRun ANALYZE, tune autovacuum analyze thresholds for large tables, raise the statistics target on skewed columns.Add optimizer hints before checking the estimates.
Generic plan or parameter sniffingMake the good plan the only plan with the right index; force custom plans only as a scoped workaround.Disable prepared statements globally.
N+1 queries or callsJoin, fetch eagerly for this use case, or batch with IN (...); add a batch endpoint for per-item HTTP calls.Cache each per-item lookup to hide the loop.
Pool contentionShorten connection hold time: fix the slow query, move I/O out of transactions. Then size the pool from measured hold time.Raise the pool size first. It moves the queue into the database.
Lock contentionKeep transactions short, avoid hot-row updates, set lock_timeout for migrations.Retry blindly on lock timeouts.
CPU throttlingSet requests from measured usage, revisit CPU limits, reduce allocation-heavy serialization.Read average CPU and conclude there is headroom.
Slow dependency or connection setupReuse clients and keep-alive connections, set timeouts, bound retries, call in parallel where independent.Raise the client timeout so fewer requests fail visibly.

A query is not fast or slow. It is fast or slow for a given data set, a given plan, and a given amount of contention.

How Tomosu helps

Most of the causes in this guide enter the codebase through an ordinary pull request. A PR adds a filter and an ORDER BY to a repository method. Another adds a lazy association to a list view, or a call to a pricing service inside a loop. Each change passes review and passes tests, because the tests run against a small database with one request at a time. The first environment with realistic data and concurrency is production.

Tomosu analyzes the standing codebase and each change for this kind of production reliability risk. For production-only latency, the useful output connects the changed code to where it will run:

These findings roll up into the Production Reliability Index. The Fragility Index and Runtime Signals capture the risk in the code path and what production telemetry says about it. For the broader practice, see How to Assess the Blast Radius of a Code Change and Pre Merge Reliability Analysis. For why dashboards alone arrive too late for this class of problem, see Production Reliability vs Observability.

Scan your repository with Tomosu →

Key takeaways

Frequently asked questions

Why is my API slow in production but fast locally?

Production runs the same code against much larger and more skewed data, different table statistics, a real network, and many concurrent requests. That changes query plans, multiplies the cost of each database or HTTP round trip, and makes requests wait for connections, locks, and CPU. Trace one slow production request to see which of these accounts for the time.

Why is a SQL query slow in production but fast on my local database?

Usually because the production table is far larger and its data is distributed differently, so the planner chooses a different plan or a missing index finally matters. Stale statistics, a generic plan for a prepared statement, and different settings such as work_mem can also change the plan. Compare EXPLAIN (ANALYZE, BUFFERS) output from both environments.

Can the same query use a different execution plan in production?

Yes. The planner chooses a plan from table statistics, parameter values, available indexes, and configuration. Any of those can differ between environments. In PostgreSQL, a prepared statement can also switch from custom plans to a generic plan after several executions, and in SQL Server a cached plan compiled for one parameter value can be reused for very different values.

Is it safe to run EXPLAIN ANALYZE in production?

EXPLAIN ANALYZE executes the statement. For a read-only query it costs one more execution. For INSERT, UPDATE, or DELETE, run it inside a transaction that you roll back, or on a replica or restored copy. The auto_explain module can log plans of slow statements automatically without running anything extra.

How do N+1 queries make an API slow only in production?

An N+1 pattern runs one query per item. On localhost each round trip costs a fraction of a millisecond and seed data has few items, so the total is small. In production each round trip crosses a real network, N is larger, and the request holds a pool connection for the whole loop, so latency and pool pressure both grow.

Why is the first request after a deploy slow?

After a deploy or failover, database and operating system caches, application caches, JIT-compiled code, and connection pools start cold. Early requests read from disk, open new connections, and run unoptimized code. If only the first minutes are slow, warm the service up before routing traffic to it rather than tuning queries.

Can Kubernetes CPU limits make an API slow?

Yes. A CPU limit is enforced by the CFS quota, so a container that uses its quota early in a scheduling period is paused until the next one. Average CPU can look moderate while requests stall in bursts. Compare container_cpu_cfs_throttled_periods_total with container_cpu_cfs_periods_total to see how often the container is throttled.

How do I reproduce a production slowdown locally?

Use production-scale data with the same skew, ideally a restored and anonymized snapshot, refresh statistics with ANALYZE, match the database settings, run queries through the same prepared-statement path, and generate concurrent load. A small uniform seed data set and single requests will not reproduce plan flips or contention.


A query that is fast locally has only been tested against a database that does not exist in production. Tomosu connects a changed query or call path to the data, concurrency, and callers it will meet after merge. Assess your repository →