Reference · Mechanics · Part 2 of 6
Where does ~30 requests/second come from? The most important thing to understand up front: nobody set it. There is no line of config that says "cap at 30." The number falls out of arithmetic — and that arithmetic turns out to be Little's Law.
In Part 1 you met the two pools, and the crucial one: the 3 ForkJoin workers shared by the whole JVM. That "3" is the seed of everything here. Once you know how many workers there are, how many downstream calls each request makes, and how long each call takes, the maximum request rate is fixed. You don't get to choose it — you can only compute it.
Only three numbers matter, and it's worth being clear about where each one comes from — because two of them are decisions and one of them is just the environment:
| Ingredient | Value | Where it comes from |
|---|---|---|
| Workers | 3 | Not typed anywhere. It's availableProcessors() − 1. On a "4-core" box that's 4 − 1 = 3. Change the cores, this changes. |
| Calls per request | 2 | A code decision. The controller fans out to service-a and service-b, so one request makes two downstream calls. |
| Latency per call | 50 ms | A stub setting. The WireMock mapping's fixedDelayMilliseconds makes every downstream call take 50 ms. |
Here is the whole thing. Notice it is all multiply and divide — there is no "plus" anywhere. In particular, the fan-out divides: the more calls each request needs, the fewer whole requests fit through the workers.
throughput = ( workers × (1000 / latency_ms) ) ÷ calls_per_request
Walk it left to right with our numbers:
1000 / latency_ms → calls per second, per worker. One call takes 50 ms; a second holds 1000 ms; so one worker manages 1000 / 50 = 20 calls every second. (The 1000 is doing nothing clever — it's only converting milliseconds into seconds.)× workers → the whole pool. Three workers, each good for 20 calls/s: 3 × 20 = 60 calls per second in total.÷ calls_per_request → turn calls into requests. Each request spends 2 calls, so 60 ÷ 2 = 30 requests per second.If the formula feels like a trick, here are three routes to it. They are the same maths from different angles, and they all land on 30.
Picture 3 machines. Each machine stamps out one part every 50 ms — that's 20 parts per second per machine, so 3 × 20 = 60 parts per second across the floor. But a finished product needs 2 parts bolted together. So the factory can ship 60 ÷ 2 = 30 products per second.
Translate the words back: the machines are the ForkJoin workers, the parts are the downstream calls, and the products are the incoming requests.
Ask a different question: how much worker time does one request cost? It makes 2 calls, each occupying a worker for 50 ms:
cost of one request = latency × calls = 0.05 s × 2 = 0.1 s (100 ms of worker time)
Now ask how much worker time you have to spend each second. Three workers each give one second of work per second, so:
budget = 3 workers × 1 s = 3 worker-seconds/second = 3000 ms/second throughput = 3000 ms/s ÷ 100 ms/request = 30 requests/second
This is the formal version, and it's why the earlier lessons kept invoking it. Little's Law says:
L = λ × W
where, plainly:
Rearrange it to solve for throughput: λ = L ÷ W. Apply it right at the bottleneck — the workers:
3 ÷ 0.05 = 60 calls per second → then ÷ 2 for the fan-out → 30 requests per second.Same answer again. The "ceiling formula" is simply Little's Law with L pinned to the number of workers (because that's the most calls that can run at once) and one final divide for the fan-out.
Here's the clean giveaway. When we actually measured the running service, it topped out around 25 req/s — not exactly 30. Real overhead (JVM warm-up, request handling, the cost of the join itself) shaves a little off the theoretical maximum.
To sharpen the contrast: the demo does contain real dials — numbers someone typed and could change directly:
| Number | Value | Where it's set |
|---|---|---|
| Tomcat threads | 200 | server.tomcat.threads.max |
| Connection pool size | 20 | cm.setMaxTotal(20) |
Those are genuine knobs. The 30 req/s is not a knob — it is the score you get when you multiply the three ingredients together.
Because it's a product, the ceiling shifts predictably the moment any input changes. Start from the base case (3 workers, 2 calls, 50 ms → 30 req/s) and change exactly one thing:
| Change | Workers | Calls/req | Latency | New ceiling |
|---|---|---|---|---|
| base case | 3 | 2 | 50 ms | 30 req/s |
| double the workers | 6 | 2 | 50 ms | 60 req/s |
| one more downstream call | 3 | 3 | 50 ms | 20 req/s |
| slower downstream | 3 | 2 | 100 ms | 15 req/s |
More workers help; more calls-per-request hurt (the fan-out divides); slower calls hurt. None of these is a "throughput setting" — they're the ingredients, and the ceiling just re-computes itself.
There is no "30" to grep for. The ceiling exists only as (cores − 1) × (1000 / latency) ÷ calls-per-request — a number the system never writes down. That invisibility is exactly why the very first thing worth doing is to make it visible: the startup banner in Lesson 1 computes this product and prints it on boot, so the ceiling stops being a surprise you discover under load.
L = λW. The same law predicts the ceiling here and the mean latency in Part 3.