Skip to content

Finding Bottlenecks

The breakpoint test is done: POST /holds keeps its p99 under 300 ms up to about 1 100 requests per second, and the on-sale needs 4 500. Now what? Adding pods blindly may change nothing β€” or make things worse. This page is about the step between measuring and fixing: reading the results correctly, locating the limiting resource, seeing which code or query consumes it, and changing one thing at a time until the bottleneck sits where you want it.


Reading the results

The stepped curve

A breakpoint test that holds each rate for a couple of minutes produces one point per step: offered load, achieved throughput, p99 and errors. The table and the plot below are typical (synthetic data with noise):

Offered (req/s)Achieved (req/s)p99 (ms)ErrorsVerdict
200201590.0 %within SLO
400396650.0 %within SLO
600594780.0 %within SLO
8008111050.0 %within SLO
10009941400.0 %within SLO
110011052370.0 %within SLO ← knee
120012046580.0 %SLO broken
1300123150005.4 %saturated ← max throughput
14001179465015.0 %saturated
16001118478930.6 %saturated
2026-11-01T21:54:32.086510 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 200 400 600 800 1000 1200 1400 1600 offered load (req/s) 200 400 600 800 1000 1200 1400 1600 throughput (req/s) knee β‰ˆ 1100 req/s Stepped breakpoint test of POST /holds offered = achieved (ideal) achieved throughput p99 latency 1 0 2 1 0 3 p99 (ms, log) SLO p99 < 300 ms

Three regions are visible, and each has a name you should use in reports:

  • Up to the knee (β‰ˆ 1 100 req/s): achieved = offered, p99 within the SLO. This is the usable capacity of this deployment.
  • Between the knee and the maximum: achieved still β‰ˆ offered, but the p99 breaks the SLO β€” the system is working, just too slowly; queues are building.
  • Past the maximum (β‰ˆ 1 250 req/s): achieved throughput falls while offered keeps growing, requests time out, errors appear. The system is saturated and, here, retrograde: overload makes it less efficient (timeouts, retries, lock convoys).

The capacity you report is the knee: "one inventory deployment of 4 pods sustains 1 100 holds/s within the SLO; it saturates at ~1 250 and degrades beyond".

Symptoms and what they usually mean

What you see Likely cause Confirm with
Latency rises with load and CPU is near 100 % CPU-bound code path CPU flame graph
Latency rises with load, CPU stays low Waiting: a pool, a lock, I/O or a slow dependency USE on pools and locks; wall-clock profile; traces
Throughput plateaus while offered load grows A saturated resource (the bottleneck) USE: find the one at 100 % or with a queue
Throughput decreases past a point Contention / thrashing / retries (retrograde, USL) Lock waits, GC time, retry counters
Bimodal latency histogram (two humps) Two code paths: cache hit vs miss, fast vs slow shard, retries Split by tag (cache status, shard)
Periodic spikes at fixed intervals GC cycles, cron jobs, database checkpoints, log rotation, token refresh Correlate timestamps with GC logs, cron schedules, DB logs
Latency grows slowly at constant load Leak or unbounded growth (memory, connections, table bloat) Soak test trends; heap after GC
Errors only in the first minutes Cold start: JIT, empty pools and caches, autoscaling lag Compare with a warmed-up run
Client p99 ≫ server-side p99 Queueing before the service (load balancer, accept queue, CPU throttling) β€” or coordinated omission in server metrics Compare gateway and service histograms; throttling counters

A catalogue of common bottlenecks

Bottleneck Typical sign Typical fix
Missing or wrong index One query dominates pg_stat_statements; Seq Scan with many rows removed Index matching the filter and sort; partial index
N+1 queries A trace with one parent span and dozens of identical DB spans Join or batch fetch (IN (...)), entity graph, DTO projection
Connection-pool exhaustion Hikari pending > 0; latency β‰ˆ pool wait Shorter transactions first; then size the pool; PgBouncer
Lock contention / hot rows DB CPU low, sessions waiting on Lock: tuple or transactionid Shorter transactions, SKIP LOCKED, finer-grained rows, queue the writes
Thread-pool exhaustion All request threads busy, CPU low Find what they wait on (usually one of the above or a dependency); timeouts; virtual threads for I/O-bound code
Slow dependency Wide HttpClient/socket-read frames in wall-clock profiles; long client spans Timeouts, circuit breaker, bulkhead, caching, async
GC pressure GC time > 5–10 %, allocation-heavy flame graph Reduce allocations (big JSON, logging), right-size the heap, a low-pause collector
CPU throttling p99 spikes with moderate average CPU; nr_throttled growing Raise/remove the CPU limit, fewer threads, more pods
Serialization ObjectMapper/JSON frames wide in CPU profiles Smaller payloads, pagination, avoid serializing twice
Synchronous logging Logger frames in the hot path; disk I/O on the node Async appenders, lower log level, sampling
Cache problems Low hit ratio; stampede after expiry; one hot key See Caching β€” failure modes
Network limits Connection resets, ephemeral-port or conntrack exhaustion at high RPS Keep-alive and connection reuse, HTTP/2, tune limits

Profiling: from "which resource" to "which code"

Metrics tell you that inventory spends its time waiting or burning CPU; a profiler tells you which code does it. Modern profilers are sampling profilers: many times per second they record the stack trace of each thread, and the code that appears in many samples is where the time goes. The overhead is low enough (typically a few percent) to profile under load, even in production.

Two kinds of profile answer different questions:

  • On-CPU profiles sample only threads that are running. They find CPU-heavy code β€” serialization, regexes, hashing, TLS.
  • Off-CPU / wall-clock profiles sample threads whatever they are doing, including waiting for locks, sockets and pools. When CPU is low but latency is high β€” the common case β€” only a wall-clock profile shows where the time goes.

Reading a flame graph

A flame graph draws the sampled stacks as nested boxes1:

  • each box is a function (a stack frame); the box below it is its caller;
  • the width is the share of samples in which the function was on the stack β€” wider means more time;
  • the x-axis is not time: frames are sorted alphabetically to merge identical stacks;
  • look for wide boxes near the top (where time is actually spent) and for unexpectedly wide subtrees.
2026-11-01T21:54:32.235099 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 0 % 25 % 50 % 75 % 100 % of samples HoldController.hold 100% HoldService.hold 100% TransactionTemplate.execute 91% HikariPool.getConnection 24% PricingClient.quote 28% HttpClient.send 28% SocketInputStream.read 28% SeatRepository.lockSeats 33% PgStatement.execute 33% SocketInputStream.read 33% Wall-clock flame graph of POST /holds (simplified)

Reading this wall-clock profile of POST /holds top-down: almost all the time is inside TransactionTemplate.execute β€” and inside it, a third of the samples wait on a socket read inside SeatRepository.lockSeats (the database is making us wait for row locks), more than a quarter wait on a socket read inside PricingClient.quote (an HTTP call inside the transaction, holding the connection and the locks), and almost a quarter wait in HikariPool.getConnection (pool exhaustion, caused by the two previous items). JSON and logging, the usual suspects, are thin slivers. A CPU flame graph of the same service would have shown mostly JSON and logging, and pointed at the wrong fix.

async-profiler for the JVM

async-profiler is a low-overhead sampling profiler for Java that avoids the safepoint bias of older JVM profilers and produces flame graphs directly2. It attaches to a running JVM:

# distroless images have no shell: attach an ephemeral debug container that shares
# the pod's process namespace, built from an image that contains async-profiler
kubectl debug -it inventory-7f9c-x2k8p --image=<your-debug-image-with-async-profiler> --target=inventory

asprof -d 30 -e cpu   -f /tmp/cpu.html   1    # on-CPU samples, 30 s, JVM is PID 1
asprof -d 30 -e wall  -t -f /tmp/wall.html 1  # wall-clock, split by thread: includes waiting
asprof -d 30 -e lock  -f /tmp/lock.html  1    # contended Java monitors and locks
asprof -d 30 -e alloc -f /tmp/alloc.html 1    # allocation hot spots (GC pressure)

Run the profiler while the load test is at the plateau β€” a profile of an idle service shows only idle code. If perf_events are not permitted in the container, -e itimer (or ctimer) is a CPU-sampling fallback. The JDK's built-in Java Flight Recorder is the alternative with no extra tool (jcmd 1 JFR.start duration=60s filename=/tmp/inventory.jfr settings=profile); open the recording in JDK Mission Control or convert it to a flame graph with async-profiler's converter.

py-spy for Python

The assistant service is Python (FastAPI). py-spy is a sampling profiler that reads the interpreter's memory from outside the process β€” no code changes, no restart3:

py-spy top    --pid 1                                   # live "top" of Python functions
py-spy record --pid 1 --duration 30 -o cpu.svg          # flame graph (SVG) of running code
py-spy record --pid 1 --duration 30 --idle -o wall.svg  # include idle/waiting threads
py-spy dump   --pid 1                                   # current stack of every thread, once

In a container py-spy needs the SYS_PTRACE capability (add it to the debug container, not to the application). For asyncio code, remember that waiting coroutines are not threads: a wall-clock view of the event loop shows where the loop is blocked β€” the classic bug being a synchronous call (a blocking HTTP client, CPU-heavy parsing) inside an async def handler.

Continuous profiling

Profiling only during incidents means you have no "before" to compare with. Continuous profiling samples every instance all the time at low frequency and stores the profiles like metrics (Grafana Pyroscope, cloud profilers; OpenTelemetry is adding profiles as a fourth signal). It enables differential flame graphs β€” this version vs the previous one β€” which make performance regressions in a canary obvious.


Database bottlenecks

The database is the most common bottleneck of a system like BoxOffice, because it is the one component that is hard to scale horizontally and that every write must reach.

Which queries?

Start from the aggregate, not from a guess. The pg_stat_statements extension records statistics for every normalized query:

SELECT left(query, 70)                         AS query,
       calls,
       round(total_exec_time)                  AS total_ms,
       round(mean_exec_time::numeric, 2)       AS mean_ms,
       round(100 * total_exec_time / sum(total_exec_time) OVER (), 1) AS pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Sort by total time, not mean: a 2 ms query called 3 000 times per second costs more than a 2 s report run once an hour.

Why is it slow? EXPLAIN ANALYZE

EXPLAIN shows the plan the optimizer chose; EXPLAIN (ANALYZE, BUFFERS) executes the query and reports real times, real row counts and pages read4. The seat-map query of the on-sale:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, section, row_label, seat_number, price_cents
FROM seats
WHERE event_id = 4242 AND status = 'AVAILABLE'
ORDER BY section, row_label, seat_number
LIMIT 8;
Before: no suitable index (illustrative output, simplified)
Limit  (cost=187432.10..187432.12 rows=8 width=40) (actual time=412.551..412.555 rows=8 loops=1)
  Buffers: shared hit=2112 read=59861
  ->  Sort  (cost=187432.10..187507.10 rows=30000 width=40) (actual time=412.549..412.551 rows=8 loops=1)
        Sort Key: section, row_label, seat_number
        Sort Method: top-N heapsort  Memory: 26kB
        ->  Seq Scan on seats  (cost=0.00..186832.00 rows=30000 width=40) (actual time=0.031..405.127 rows=31480 loops=1)
              Filter: ((event_id = 4242) AND (status = 'AVAILABLE'::text))
              Rows Removed by Filter: 5968520
Planning Time: 0.142 ms
Execution Time: 412.601 ms

How to read it, bottom-up (the innermost node runs first):

  • Seq Scan with Rows Removed by Filter: 5968520 β€” PostgreSQL read all 6 million seats of all events to keep 31 480. That is the problem.
  • Buffers: … read=59861 β€” about 60 000 pages (8 KB each, β‰ˆ 470 MB) came from disk or the OS cache rather than PostgreSQL's buffer cache. Under load, this is I/O saturation.
  • Sort … top-N heapsort β€” sorting 31 480 rows to keep 8; cheap here, but avoidable.
  • Compare estimated rows=30000 with actual rows=31480: close, so statistics are fine. A large mismatch (100Γ— off) would point at stale statistics (ANALYZE) or correlated columns.

A partial index that matches both the filter and the sort order lets PostgreSQL read the first 8 available seats directly, in order:

CREATE INDEX CONCURRENTLY seats_available_idx
    ON seats (event_id, section, row_label, seat_number)
    WHERE status = 'AVAILABLE';
After: index scan (illustrative output, simplified)
Limit  (cost=0.43..9.87 rows=8 width=40) (actual time=0.041..0.058 rows=8 loops=1)
  Buffers: shared hit=5
  ->  Index Scan using seats_available_idx on seats  (cost=0.43..35392.11 rows=30000 width=40) (actual time=0.040..0.055 rows=8 loops=1)
        Index Cond: (event_id = 4242)
Planning Time: 0.210 ms
Execution Time: 0.079 ms

From 412 ms to 0.08 ms and from ~62 000 pages to 5. CONCURRENTLY builds the index without blocking writes β€” essential on a live table. (Indexes are not free: each one slows down writes and uses memory; index what the workload model says is hot.)

In the BoxOffice code: an index instead of a cache (lab 6)

Hands-on 6 β€” Indexes does this on the slow endpoint since lab 1, the catalog listing "upcoming events in a city, soonest first". EXPLAIN ANALYZE showed a Parallel Seq Scan and a sort of 200,000 rows to return 20. An index in starts_at order lets PostgreSQL walk the events already sorted and stop after one page (189 index entries read): in the lab's run the page query went from ~37 ms to 0.62 ms, and the count from ~14 ms to ~4 ms through an Index Only Scan. The migration is a new file β€” V1 and V2 are never edited β€” and the lab measures it with the cache turned off, so the index is judged on its own.

services/catalog/src/main/resources/db/migration/V3__index_events.sql
-- Lab 6: indexes for the listing "upcoming events in a city, soonest first".
-- Walk events in starts_at order and stop after one page, instead of sorting 20,000 rows:
CREATE INDEX events_starts_at_idx ON catalog.events (starts_at, id);
-- Count/filter the events of a city's venues without reading the whole table:
CREATE INDEX events_venue_starts_at_idx ON catalog.events (venue_id, starts_at);
CREATE INDEX venues_city_idx ON catalog.venues (city);

Lock contention and hot rows

When everyone wants the same seats, transactions queue for row locks. Find who waits for whom:

SELECT pid,
       pg_blocking_pids(pid)        AS blocked_by,
       wait_event_type, wait_event,
       now() - query_start          AS waiting_for,
       left(query, 60)              AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

Three changes reduce contention, in order of effort:

  1. Keep transactions short. Never call another service (pricing, payment) while holding a transaction: compute the price before BEGIN, call the payment provider after COMMIT (with an outbox or a saga β€” see Messaging β€” patterns).
  2. Do not fight for the same row. When the fan asks for "2 seats in section A", any two free seats will do. SKIP LOCKED lets concurrent transactions take different free rows instead of queuing on the first one:

    BEGIN;
    SELECT id FROM seats
    WHERE event_id = 4242 AND section = 'A' AND status = 'AVAILABLE'
    ORDER BY row_label, seat_number
    LIMIT 2
    FOR UPDATE SKIP LOCKED;
    UPDATE seats
       SET status = 'HELD', held_by = $1, hold_expires_at = now() + interval '10 minutes'
     WHERE id = ANY($2);
    COMMIT;
    
  3. Control admission. If 50 000 fans try to hold seats in the same second, no locking strategy makes PostgreSQL happy. A waiting room admits fans at the rate the inventory can absorb β€” turning contention into an orderly queue (Messaging β€” queues).

Sizing the connection pool

A connection pool can be a bottleneck in both directions: too small and requests wait for a connection; too large and the database spends its time switching between hundreds of sessions, fighting over locks and memory. Size it with Little's Law β€” connections needed = throughput Γ— time each request holds a connection:

Design of POST /holds Connection held for At 1 500 holds/s Per pod (10 pods)
Pricing call inside the transaction 6 ms SQL + 40 ms HTTP = 46 ms 1 500 Γ— 0.046 β‰ˆ 69 connections busy ~7, more for bursts
Pricing call before the transaction 6 ms 1 500 Γ— 0.006 β‰ˆ 9 connections busy ~1, keep a small pool

The first fix is not a bigger pool β€” it is holding the connection for less time. Then size for the database's capacity: PostgreSQL works best with a number of active connections in the order of a few times its CPU cores (HikariCP's often-quoted starting point is connections β‰ˆ cores Γ— 2 + effective spindles)5. Twenty pods Γ— a pool of 20 = 400 connections is far beyond that; put PgBouncer in transaction-pooling mode between the pods and the database so that hundreds of client connections share a few dozen server connections. And fail fast:

inventory/src/main/resources/application.yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 10            # a fixed-size pool: no ramp-up during the spike
      connection-timeout: 2000    # wait at most 2 s for a connection (the default is 30 s)

In the BoxOffice code: a pool that moves the knee, and a pooler (labs 2 and 6)

In Hands-on 2 the pool of catalog (10 connections, DB_POOL_SIZE) is the first resource to saturate, and raising it to 30 moves the knee up β€” until the single CPU of catalog or PostgreSQL saturates next. Hands-on 6 β€” Replicas and PgBouncer then counts what bigger pools cost: every replica of every service opens its own. In transaction pooling, PgBouncer lends one of 20 real connections for one transaction at a time: in the lab's run PostgreSQL saw about 60 application connections, 20 of them from PgBouncer serving all of inventory. Without it, INVENTORY_POOL_SIZE=40 against max_connections=80 fails with too many clients already; with it, the same demand becomes queueing (cl_waiting). The price: session features (Flyway's locks) must bypass the pooler.

infra/pgbouncer/pgbouncer.ini
; PgBouncer: thousands of client connections, a few dozen real PostgreSQL connections.
[databases]
boxoffice = host=db port=5432 dbname=boxoffice

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = boxoffice

; transaction pooling: a server connection is lent for ONE transaction, then returned
pool_mode = transaction
; real connections to PostgreSQL per (database, user) pair
default_pool_size = 20
; client connections PgBouncer accepts (all services and replicas together)
max_client_conn = 1000
; protocol-level prepared statements work in transaction mode (PgBouncer >= 1.21)
max_prepared_statements = 200
ignore_startup_parameters = extra_float_digits,options

One change at a time: an optimization log

Keep a log of every run: what changed, the new knee, and what saturated next. A bottleneck removed reveals the next one; the goal is not "no bottleneck" (there is always one) but the bottleneck in the right place with enough capacity. The log below is illustrative of a typical campaign on POST /holds:

Run Change (one per run) Knee (holds/s within SLO) What saturated next
1 Baseline: 4 pods, pool 10 400 PostgreSQL I/O (seat-map Seq Scan)
2 Partial index on available seats 700 Hikari pool: 10/10 active, 150 pending
3 Pricing call moved out of the transaction 1 100 Row-lock waits on hot sections
4 FOR UPDATE SKIP LOCKED for seat selection 1 800 inventory CPU 85 % (JSON, TLS)
5 4 β†’ 10 pods (HPA min), PgBouncer 3 400 PostgreSQL primary CPU 80 %
6 Waiting room admitting 3 000 holds/s 3 000 admitted, p99 140 ms β€” by design: admission is the limit

After run 6 the bottleneck is deliberate: the waiting room's admission rate, set below the database's knee. Scaling further would require partitioning inventory by event or venue (Databases β€” partitioning) β€” a much bigger change, now justified by numbers.

Try it: the monolith scenario (one app server and one PostgreSQL, the breakpoint profile) produces the first two lines of such a log in a few minutes. Run it as it is and open the Report: the bottleneck is app, already 86 % busy at about 430 req/s, and the system breaks at about 590 req/s. Change one thing β€” select app and raise its replicas to 6 β€” and run again: the breaking point jumps to about 2,520 req/s, and the bottleneck moves to db, 87 % busy at about 2,180 req/s. Now change the wrong thing: 12 replicas instead of 6. The breaking point stays at about 2,520 req/s while the monthly cost goes from about 1,170 to 1,890 USD β€” adding capacity to a component that is not the bottleneck buys nothing.

In the interview

In the "bottlenecks and trade-offs" step, walk the request path and name the first resource to saturate with a number: "At 4 500 holds/s the primary database is the bottleneck β€” every hold is a locking write. I would keep transactions short, use SKIP LOCKED so fans do not queue on the same row, size pools with Little's Law behind PgBouncer, and put a waiting room in front so admission never exceeds what the database can take." Mentioning wall-clock vs CPU profiling and EXPLAIN ANALYZE shows you have actually found bottlenecks, not just drawn boxes.

Best practices

  • Turn on pg_stat_statements and slow-query logging in every environment from day one β€” for example log_min_duration_statement = 200ms; the question "which query?" is asked during an incident, about data that must already exist.
  • Ship every index as a new versioned migration, and build it CONCURRENTLY on large live tables β€” the schema change is reviewed, repeatable in every environment and does not block writes. In BoxOffice: V3__index_events.sql of lab 6.
  • Put a pooler in front of PostgreSQL once several services and replicas connect, and fail fast on connection waits β€” transaction pooling keeps the server connections near what Little's Law needs; a 2 s connection-timeout instead of 30 s turns a queue into a clear error. In BoxOffice: infra/pgbouncer/pgbouncer.ini of lab 6.
  • Keep the optimization log in the repository β€” one row per run: the change, the new knee, what saturated next; it is the evidence behind every capacity number. In BoxOffice: the pool 10 vs pool 30 sweeps of lab 2.
  • Run continuous profiling in production at a low sampling rate β€” a few percent of overhead buys the flame graph of the incident before anyone asks for it.

Pitfalls

  • Profiling an idle system: profile at the load-test plateau.
  • CPU flame graphs for a waiting problem: use wall-clock (off-CPU) profiles when CPU is low.
  • Optimizing by mean query time: sort by total time; frequent cheap queries dominate.
  • EXPLAIN without ANALYZE on a slow query: estimates are not measurements.
  • Growing the pool to fix pool waits: first shorten what holds the connection; bigger pools move the queue into the database.
  • Changing three things at once: you will not know which one helped β€” or which one hurt.

How it connects

flowchart LR
    CURVE["stepped curve<br/>and symptoms"] --> RES["which resource?<br/>USE β†’ Methodology"]
    RES --> PROF["which code? flame graphs,<br/>async-profiler, py-spy"]
    RES --> DBQ["which query? pg_stat_statements,<br/>EXPLAIN ANALYZE"]
    DBQ --> IDX["indexes, N+1<br/>β†’ Databases models"]
    DBQ --> LOCK["hot rows, SKIP LOCKED<br/>β†’ Databases transactions"]
    DBQ --> POOL["pool sizing by Little's Law,<br/>PgBouncer"]
    RES --> CACHE["low hit ratio, stampede<br/>β†’ Caching failure modes"]
    RES --> DEP["slow dependency<br/>β†’ Reliability patterns"]
    PROF --> LOG["one change at a time:<br/>the optimization log"]

Palette components: app (CPU, GC, thread pools, serialization) Β· sql (indexes, locks, connection pools) Β· cache (hit ratio, hot keys, stampedes) Β· queue (taking contended writes off the request path) Β· thirdparty (slow dependencies) Β· tracing (N+1 queries show up as dozens of identical spans) and metrics (pool and throttling counters). Scenarios: monolith (breakpoint profile: find the first resource to saturate), read-heavy-cache, flash-sale (hot seat rows). Continues in: Databases β€” transactions, Databases β€” partitioning, Caching β€” failure modes, Messaging β€” queues and pub/sub, Reliability β€” patterns, Capacity planning.



  1. GREGG, B. The Flame Graph. ACM Queue, v. 14, n. 2, 2016. ↩

  2. PANGIN, A. et al. async-profiler. ↩

  3. FREDERICKSON, B. py-spy: Sampling profiler for Python programs. ↩

  4. PostgreSQL documentation. Using EXPLAIN; pg_stat_statements. ↩

  5. HikariCP wiki. About Pool Sizing. ↩