Interactive companion · What performance really means in software engineering

Every slow system is slow somewhere specific.

Slow software is usually waiting on something boring: a busy main thread, a fresh TLS connection, a cold runtime, a pool with no spare connections, a row lock, a GPU batch queue. Performance is queue ownership. This page makes those waits visible. The models are small, seeded, and easy to break on purpose.

Simulations · seeded runs Dependencies · 0 Web fonts · 0 Requests · 1 HTML file
Live · one request path under load Dots are requests · stacks show wait
Requests pass through browser, network, CDN, runtime, database, and inference queues.
Hot queue now → database pool The bottleneck moves.
Browser
Network / deps
CDN / cache
Runtime
Database
AI inference
SPAN 01 · THE PATH for the static & edge advocate

Add up the wait.

A user's wait is a stack of smaller waits. Pick a scenario, hover a stage, and toggle fixes. Some fixes save a pile of time. Some save nothing because that layer was not your problem.

Request waterfall · one visible action

rough model · plausible numbers
User-visible wait
--

Disabled fixes target stages that are already near zero. That is how you know the shiny optimization has no job here.

What to notice: the stack that wins here removes the wait users are actually paying for. Find that wait first; the architecture gets a lot less mysterious.
SPAN 02 · THE TAIL for the operability & cost advocate

Averages hide the story.

Most requests can look fine while a small slow path ruins sessions. Add a lock wait, cold start, or cache miss to a tiny slice of traffic and watch the median stay calm while the tail catches fire.

"A p50 tells you what a typical user saw on a typical day. A p95 or p99 tells you where trust starts to erode."

from the article

Latency distribution · 4,000 sampled requests

simulated · lognormal + slow path
2.0%
+1,200 ms
Latency histogram. Current mean and percentile values are listed below.
mean
--
p50
--
p95
--
p99
--
Tail amplification
--

of page loads contain at least one request slower than your p99, when a page fires 30 backend requests.

30

P(hit tail) = 1 - 0.99k · assumes independent requests

A "1% of requests" problem becomes a "lots of sessions" problem once one page fans out to dozens of backend calls. Your p99 is not rare. It is just diluted.

What to notice: the mean barely moves. The slow path is not noise; it is part of the product people use. Track p95 and p99 by route.
SPAN 03 · THE POOL for the backend & data advocate

A pool is an admission controller, not a throughput knob.

Here is the incident version: Poisson arrivals, a connection pool, and an 8 core database. Past about 8 concurrent queries, work starts sharing CPU and paying a contention tax. You show up with the pool open at 100 and p99 in seconds. Shrink the pool and the tail collapses. That is the HikariCP pool-sizing lesson in miniature: sometimes the fix is letting fewer requests in.

Pool simulator · 30 s per run

discrete-event · seeded
350 rps
100 conns
22 ms
p50 total
--
p95 total
--
p99 total
--
throughput
--

Pool wait vs. execution

Pool wait and execution time are compared at p50, p95, and p99.
pool wait (visible queue) execution (queue moved inside the DB)

p99 vs. pool size: the U curve · swept with your current rps and query work

The p99 latency curve compares pool sizes from 2 through 120 connections.

At pool = 100, pool wait looks great because the queue moved inside the database. Execution gets fat instead. Pull the pool back toward the cores line and the wait becomes visible and cheap. Go too far, down around 4, and you starve the database. That is the left side of the U.

What to notice: before saturation, more connections can reduce waiting. After saturation, they make the tail worse. Cap the pool and chart pool wait separately from execution.
SPAN 04 · THE SCAN for the backend & data advocate

OFFSET is an explicit latency bill.

Even with a matching ordered index, rows skipped by OFFSET must still be visited before PostgreSQL discards them. Keyset pagination resumes from the prior page's cursor and reads the next 50 rows. On a stable ordered dataset, both produce the same next page for very different bills. Drag the depth and race them.

Pagination race · 1,000,000-row ordered tenant slice

cost model · ordered rows visited
next page 400 · 19,950 rows already seen

OFFSET

An ordered scan visits earlier index entries before returning the requested page.
rows visited --est. time --

KEYSET

An index seek resumes at the prior cursor and reads the next 50 rows.
entries visited --est. time --

This models sequential navigation with the prior page's cursor already available, not a random jump to page 400. It assumes a stable snapshot so both queries identify the same rows; concurrent inserts can make OFFSET pages drift. Keyset needs an index matching (tenant_id, created_at DESC, id DESC).

What to notice: keyset matches the product behavior better. Work that grows with page depth will hurt right when the product succeeds.
SPAN 05 · THE BATCH for the AI & data advocate

Utilization is not the goal.

An inference server batches requests to keep the GPU busy, but the batcher buys utilization with queue time. This model puts a dynamic batcher (max batch 8) in front of one GPU. Raise arrival rate until utilization looks perfect. Then look at goodput: the rate of requests that meet the TTFT SLO. It can move the other way.

Dynamic batcher · 60 s simulated · TTFT vs. utilization

event-driven · seeded
18 rps
25 ms
≤ 400 ms
GPU utilization, the tempting chart
--
busy time ÷ wall time
Goodput rate, the user chart
--
-- of arrivals meet the TTFT SLO
TTFT
-- p50
-- p95

Batch size distribution

A histogram compares dispatched batch sizes from one through eight.

SLO attainment vs. max_queue_delay · swept with your current rps and SLO

SLO attainment and GPU utilization are compared across queue delays.

Two regimes are worth finding. Below ~14 rps, a batch of one keeps up, so forced delay only adds a TTFT floor. Past ~14 rps, batches of one fall behind. At D=0 the GPU is pegged at 100%, the dashboard looks happy, and the queue grows anyway. There, delay buys real capacity: SLO attainment rises, so goodput rate jumps while utilization falls. Keep pushing D and the delay floor eats your SLO. The knob is max_queue_delay_microseconds; the sweep chart plots attainment and utilization.

What to notice: more tokens per second does not help if users wait longer before anything starts. For interactive products, goodput rate under SLO beats utilization.
SPAN 06 · THE STREAM for the frontend advocate

Same latency. Different wait.

Both responses take exactly the same total time to finish. One hides the answer until the end. The other shows progress after the first token. Streaming does not make the model faster; it moves the wait from silence to reading.

Perceived-wait race · identical end-to-end time

Blocking0.00 s
Press Run both
TTFC --E2E --
Streaming0.00 s
Press Run both
TTFC --E2E --
600 ms
30 ms

TTFC = time to first content. The streamed text makes the point while it runs.

What to notice: people tolerate a long answer that starts quickly better than a short answer hidden behind a spinner. Stream interactive AI by default.
SPAN 07 · THE ORDER OF ATTACK

The language fight is #7.

The article's default order of attack for ordinary web work. The ranking matters because every layer above your current fix keeps charging rent while you argue about the lower one. Hover a row to see which demo maps to it.

For specialized systems, follow the wait: model and data path first for AI, event topology first for realtime, storage layout first for analytics. Frameworks and languages still matter. They rarely matter first.