Performance · 07

Spring, HTTP & Network Performance

Where does a request spend its time between the load balancer and your controller? This lab runs real embedded Tomcat, Jackson and plain HTTP/TLS clients on a 12-core laptop-class machine, changes one variable at a time (thread pool, virtual threads, payload, connection reuse) and shows measured before/after numbers. AWS-side behaviour (ALB idle timeouts, cross-AZ) is explained from documented defaults and clearly labelled where it was not measured.

12
Chapters
5
Measured labs
16
Diagrams
3
Programs to run
01
Chapter One - The Problem

Idle CPU, Two-Second Latency

OrderService is our running scenario: a Spring Boot 3 / Java 21 REST service behind an AWS Application Load Balancer, calling a PaymentGateway over HTTP. It has just been load-tested at 400 concurrent users and the dashboard shows something that confuses the team: latency is two seconds, but CPU is nearly idle. Nothing is slow. Everything is waiting.

The request path we will tune - OrderService behind an ALB
One GET /orders/42 on its way through the stack - every box is a place latency hides Client keep-alive reuse DNS cache TTL TCP + TLS 1-3 round trips ALB / NLB idle timeout Accept queue backlog, max-conn Worker thread threads.max / virtual Filters + MVC chain, resolvers Downstream pooled client Jackson DTO size, gzip Response bytes on the wire request admitted to a thread Red = where our lab experiments found the biggest wins (thread pool, connection setup). Gold = AWS-side settings that fail only under real infrastructure (illustrative, not run here).

This page is a laboratory for the layer between the JVM and the database: the embedded Tomcat, Spring MVC, Jackson, the HTTP client pool, and the network (TCP, TLS, keep-alive, load-balancer timeouts). Every number below comes from code that was compiled and run in this sandbox: a 12-core laptop-class Linux box, Java 21.0.2, Tomcat 10.1.30 (the version Spring Boot 3.3.4 embeds), Jackson 2.17.2. The machine was shared with other jobs (load average 20-37 on 12 cores) while these ran, so absolute values are noisy; the shape and the ratios are what to take away. Your numbers will differ - teaching you to get your own is the point.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

The symptom, measured with the code in chapter 3: 400 concurrent clients, each request needs a 50 ms downstream PaymentGateway call. With Tomcat limited to 10 request threads:

real run - embedded Tomcat, maxThreads=10
maxThreads=10 clients=400 req= 1580 throughput= 198 req/s avg=2020.5 ms p50=2017.6 p95=2035.9 p99=2041.3 errors=0

No errors, no CPU. Just a flat 2 second latency for every request and 198 requests per second. A 50 ms handler became a 2000 ms response. Chapter 3 shows why that is exactly arithmetic, not mystery.

Step 2 · Hypothesis

The request-thread pool is the bottleneck: 10 threads can only have 10 requests in flight, the other 390 queue. (Little's Law, see Measure First: in-flight requests L = throughput × latency.)

Step 3 · Measurement

Tomcat's own view of the pool (tomcat.threads.busy / tomcat.threads.config.max in Micrometer, or a jcmd <pid> Thread.print showing every http-nio-8080-exec-N thread parked in the downstream call) and the load tool's latency histogram. Below we use both a thread dump and the closed-loop client.

Step 4 · Diagnosis

All 10 workers busy 100 % of the time and zero idle capacity; the queue is in front of them (in the OS accept backlog / connector, invisible to application timers). Latency measured at the client includes that wait; http.server.requests measured inside the app does not. That gap - client sees 2000 ms, server metric says 50 ms - is the classic fingerprint of an exhausted request pool.

Step 5 · Optimization

Change exactly one variable: server.tomcat.threads.max. Then, separately, replace the pool with virtual threads. Chapter 3 and 4 do both and compare.

Step 6 · Verification

Same client, same handler, same machine. Result teaser: 10 threads → 198 req/s; 50 → 983; 200 → 3,874; virtual threads → 5,950. Full table in chapter 3.

Key insight: A restaurant with 10 waiters, where each waiter must stand at the kitchen door for 50 ms per order. Customers queue at the entrance, and every customer sees a long wait even though nobody is running around. More waiters (or waiters who can put the order down and do something else - virtual threads) fixes it; a faster chef does not.
02
Chapter Two - Mental Model

The Request Pipeline and Its Queues

A request is not one cost, it is a pipeline. Each stage below is a real class in Spring Framework 6 / Boot 3, and each can be a source of latency, CPU, or queueing. You cannot tune what you cannot place on this pipeline.

A socket is accepted by the Acceptor and registered with a Poller. No thread is consumed while a connection is idle: NIO multiplexes thousands of sockets onto a handful of poller threads.
▶
Idle connections cost no thread
Bytes arrive and a worker from the request executor is borrowed. This is the scarce resource: with platform threads one worker is one request in flight for the whole duration, including every blocking call it makes.
▶
The worker pool is the scarce resource
The filter chain, DispatcherServlet, handler mapping and argument resolvers run. Mostly microseconds; real cost appears only with heavy filters (e.g. a network call per request for token introspection) or large @RequestBody parsing.
▶
Usually microseconds, rarely the problem
Your controller calls services, the database and downstream HTTP. Almost always 90 % or more of the latency (see the 800 ms case on the Database page). The worker is parked, not computing.
▶
Most latency lives here
HttpMessageConverter (Jackson) serialises the result. CPU proportional to the object graph: returning an entity graph instead of a slim DTO multiplies it (chapter 5 measures about 10x).
▶
Payload shape sets CPU and bytes
The response is flushed and the worker returns to the pool. Tomcat keeps the TCP connection open (keep-alive) so the next request skips the handshake - or closes it and the next one pays for a new one (chapter 6).
▶
Keep-alive decides the next call's cost
The Spring MVC request path
What Spring MVC does for one request, in order (each arrow = real class in Spring 6 / Boot 3) Tomcat Poller + worker NIO selector hands the socket to a worker thread Servlet filter chain CharacterEncoding, RequestContext, Observation, Security (about a dozen) DispatcherServlet.doDispatch one servlet for every request HandlerMapping RequestMappingHandlerMapping: URL + method to a handler method Interceptors preHandle HandlerInterceptor chain (your own, locale, CORS) Argument resolvers @PathVariable, @RequestBody (Jackson read), @Valid Controller + service YOUR code: DB, downstream calls (the part you control most) Return value handler HttpMessageConverter: Jackson write, Content-Type, compression Filters unwind, response flush metrics recorded, bytes written to the socket

Structure diagram of the real class flow; not a measured profile.

Where a request can wait inside Tomcat
Inside the embedded Tomcat connector: three places a request can wait Clients 400 open sockets OS accept backlog accept-count default 100 Connector Acceptor + Poller max-connections 8192 Worker pool (executor) threads.max default 200 min-spare 10 queue: unbounded task queue Wait #1: when all connections are used (max-connections) new TCP connects sit in the OS backlog (accept-count). Wait #2: when all worker threads are busy, request tasks wait in the executor queue - this is our 2-second queue. Wait #3: a kept-alive connection is idle (no thread held) until the next request or until keep-alive timeout closes it. With virtual threads the executor is replaced by one that starts a new virtual thread per task: no fixed pool, no queue. Defaults shown are Tomcat 10.1 / Spring Boot 3.x documented defaults - confirm in your version.

Structure diagram. Defaults per Tomcat 10.1 / Boot 3 documentation.

The Boot properties that map onto this picture
Property (application.yml)What it controlsNotes
server.tomcat.threads.maxSize of the worker poolDefault 200. Platform threads: each costs roughly a thread stack (about 1 MB reserved) plus scheduler overhead.
server.tomcat.threads.min-spareWorkers kept alive when idleDefault 10. Raise if bursts arrive faster than threads spawn.
server.tomcat.accept-countBacklog of pending connections once max-connections is reachedDefault 100. Beyond it the client sees connection refused / timeouts.
server.tomcat.max-connectionsSockets Tomcat will hold open (NIO)Default 8192. Must exceed your LB connection fan-in plus idle keep-alives.
server.tomcat.connection-timeoutTime to wait for the request line / data on a new connectionInteracts with keep-alive timeout; see chapter 8.
spring.threads.virtual.enabledUse virtual threads for request handling and async executionBoot 3.2+, needs Java 21+.
server.compression.enabledgzip responses above a size thresholdTrades CPU for bytes; see chapter 5.
spring.jpa.open-in-viewKeep the DB connection for the whole request (default true)Hidden pool hog: see Database.
Cross-reference: The framework plumbing itself (how DispatcherServlet resolves arguments, how converters are chosen) is explained in REST Controllers & the MVC Request Path. This page only asks what each stage costs.
03
Chapter Three - Lab One - Request Threads

Tomcat: threads.max, Queues and Little's Law

Our first lab changes one variable: the size of Tomcat's request-thread pool. The server is real embedded Tomcat 10.1.30, the same artifact Spring Boot 3.3.4 starts. Its servlet sleeps 50 ms to model the PaymentGateway call, then writes a 25-byte JSON body.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

Throughput stalls at 198 req/s and every response takes 2 seconds, with 400 clients and almost no CPU use (chapter 1).

Step 2 · Hypothesis

Throughput = threads / service time = 10 / 0.050 = 200 req/s. Latency for 400 closed-loop clients = 400 / 200 = 2.0 s. If the hypothesis is right, 50 threads should give 1000 req/s and 0.4 s, and 200 threads 4000 req/s and 0.1 s.

Step 3 · Measurement

A closed-loop client: 400 virtual threads, each looping HttpClient.send() over keep-alive connections, 2 s warm-up then 8 s measured. The server and client are in one JVM per run; full source in chapter 10. Command:

shell - one run per configuration
javac -cp $TOMCAT:$ANN -d out TomcatLab.java java -cp out:$TOMCAT:$ANN TomcatLab threads 10 400 8 java -cp out:$TOMCAT:$ANN TomcatLab threads 50 400 8 java -cp out:$TOMCAT:$ANN TomcatLab threads 200 400 8
Step 4 · Diagnosis

The raw result of all four runs (laptop-class 12-core machine, noisy shared load):

real output
maxThreads=10 clients=400 req= 1580 throughput= 198 req/s avg=2020.5 ms p50=2017.6 p95=2035.9 p99=2041.3 errors=0 maxThreads=50 clients=400 req= 7861 throughput= 983 req/s avg= 405.3 ms p50= 404.5 p95= 417.2 p99= 430.1 errors=0 maxThreads=200 clients=400 req= 30991 throughput= 3874 req/s avg= 102.9 ms p50= 101.0 p95= 117.5 p99= 147.9 errors=0 virtual-threads clients=400 req= 47596 throughput= 5950 req/s avg= 67.2 ms p50= 60.9 p95= 104.3 p99= 159.7 errors=0
ConfigThroughputTheory (threads / 50 ms)avgp95p99
maxThreads=10198 req/s2002020 ms2036 ms2041 ms
maxThreads=50983 req/s1000405 ms417 ms430 ms
maxThreads=2003,874 req/s4000103 ms118 ms148 ms
virtual threads5,950 req/s8000 (400/0.05)67 ms104 ms160 ms

Two things stand out. First, the pool sizes match Little's Law to within 3 %: this is a queueing problem, not a code problem. Second, latency has a floor of 50 ms (the handler) and the thread-per-request model only reaches it once threads ≥ concurrency (200 threads, 400 clients: still queueing, latency 103 ms = 2 × 50).

Step 5 · Optimization
application.yml - the ONE variable we changed
server: tomcat: threads: max: 200 # was 10 in the broken config min-spare: 20 accept-count: 100 max-connections: 8192
Step 6 · Verification
Throughput per configuration (measured)
Measured throughput, 400 closed-loop clients, 50 ms blocking handler (req/s) maxThreads=10 198 maxThreads=50 983 maxThreads=200 3874 virtual threads 5950 Theory (threads / 0.050 s): 200, 1000, 4000 req/s. Measured values sit at 99 %, 98 %, 97 % of theory. Virtual threads: ceiling would be 400 / 0.050 = 8000; the shared, loaded box and the client capped it first.

Closed-loop, 400 clients; embedded Tomcat 10.1.30; Java 21.0.2; shared 12-core machine.

Latency collapses as the pool grows, then hits the handler's own cost (measured points on the Little's Law curve)
Latency of one request vs request-thread pool size (400 clients, 50 ms handler) 10 50 200 server.tomcat.threads.max (log scale) 0 500 1000 1500 2000 ms 10 thr: 2020 ms 50 thr: 405 ms 200 thr: 103 ms dashed: latency = clients x 0.050 / threads (Little's Law), floor 50 ms

Dots are measured averages; the dashed line is the formula.

But more threads is not free - and not always the answer

Setting threads.max to 2000 is the instinctive fix and it often moves the bottleneck downstream: 2000 request threads now hit a database pool of 10, or a PaymentGateway that rate-limits at 100 concurrent calls. The queue merely relocates (from Tomcat's executor to Hikari's getConnection() wait) and the timeouts you set there start firing. Size the pool from the narrowest downstream resource, not from the incoming concurrency:

rule of thumb for platform-thread request pools
threads needed = target_throughput (req/s) x average_time_a_request_holds_a_thread (s) example = 1000 req/s x 0.050 s = 50 threads (plus headroom for variance, e.g. x 1.5) if threads_needed > smallest_downstream_pool -> you are queueing there, not here
⚠ Production gotchas: the request pool
  • Leaving the default 200 and calling it done. 200 platform threads is plenty for a 50 ms handler at 4,000 req/s but not for a 500 ms handler at 1,000 req/s (needs 500). Diagnose: tomcat.threads.busy pinned at tomcat.threads.config.max while CPU is low.
  • Measuring latency only on the server. http.server.requests starts when a worker picks the request up; queue time is invisible. Compare it with the load balancer's TargetResponseTime or the client-side number.
  • Raising threads.max without raising the DB pool or setting timeouts. You convert a polite queue in Tomcat into a thundering herd on the database.
  • Huge accept-count as a "safety" net. It hides overload as latency instead of failing fast. Prefer a modest backlog plus load shedding (see Distributed Systems).
04
Chapter Four - Lab Two - Virtual Threads and the MVC vs WebFlux Question

Remove the Pool, Find the Next Bottleneck

Chapter 3 showed that thread-per-request needs as many threads as requests in flight. Virtual threads (JEP 444, final in JDK 21) change that economics for I/O-bound services: a blocked virtual thread is unmounted from its carrier and costs a few hundred bytes of heap instead of a megabyte-class OS thread. Spring Boot 3.2+ wires them in with one property.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

Even after raising threads.max to 200, 400 concurrent clients still queue (latency 103 ms for a 50 ms handler). To serve 400 in flight we would need 400+ platform threads; at 5,000 concurrent calls, 5,000 threads: stack memory, scheduler pressure, and GC root scanning grow with every one.

Step 2 · Hypothesis

If requests spend their time blocked, giving every request its own virtual thread removes the pool as a limit; latency should fall to about the handler time (50 ms) until some other resource saturates.

Step 3 · Measurement

Same TomcatLab, same client; the only difference is the executor Tomcat uses (the mechanism spring.threads.virtual.enabled=true configures for Boot's Tomcat):

Java - what changes between runs
Http11NioProtocol p = (Http11NioProtocol) connector.getProtocolHandler(); if (virtual) p.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); else p.setMaxThreads(maxThreads);
shell
java -cp out:$TOMCAT:$ANN TomcatLab virtual 0 400 8
Step 4 · Diagnosis
real output (from chapter 3 run)
virtual-threads clients=400 req= 47596 throughput= 5950 req/s avg= 67.2 ms p50= 60.9 p95= 104.3 p99= 159.7 errors=0

Throughput rose 54 % over the 200-thread pool and average latency fell from 103 ms to 67 ms with no tuning knob at all. p99 (160 ms) is not better than the 200-thread run (148 ms): at this load the machine is also running the client and 12 cores are busy scheduling, so the tail is CPU contention, not queueing. Virtual threads moved the bottleneck from "pool size" to "CPU and downstream".

Step 5 · Optimization
application.yml (Spring Boot 3.2+, Java 21+)
spring: threads: virtual: enabled: true # Tomcat request threads and @Async / task executors become virtual threads

What it does in Boot 3.2+: the embedded Tomcat protocol handler gets a virtual-thread-per-task executor, and Boot's auto-configured applicationTaskExecutor uses virtual threads. server.tomcat.threads.max then no longer bounds concurrency.

Step 6 · Verification
Why a virtual thread removes the pool limit
Same blocking call, two scheduling models Platform threads (pool of N = 10) worker 1 blocked in PaymentGateway call worker 2 blocked in PaymentGateway call worker 3 blocked in PaymentGateway call worker 4 blocked in PaymentGateway call worker 5 blocked in PaymentGateway call ... 390 other requests wait in the executor queue Virtual threads (JDK 21) vthread 1 parked (continuation) vthread 2 parked (continuation) vthread 3 parked (continuation) vthread 4 parked (continuation) vthread 5 parked (continuation) ...400 carrier threads (ForkJoinPool, about one per core): 12 a vthread occupies a carrier only while it computes A blocking call on a virtual thread unmounts it; the carrier runs another vthread. Pinning (synchronized / native frames) keeps the carrier blocked: fixed for synchronized in JDK 24 (JEP 491). CPU-bound code gets no benefit: you still have only 12 cores.

Mechanism diagram (JEP 444). The 12 carriers are this machine's core count.

MetricPlatform pool (200)Virtual threadsComment
Throughput3,874 req/s5,950 req/s+54 %, measured
Average latency103 ms67 msmeasured
p99148 ms160 msnot better: CPU contention on a shared box
Concurrency limitthreads.maxnonedownstream pools now matter
⚠ Production gotchas: virtual threads in Spring Boot
  • They do not make CPU-bound work faster. The concurrency page (06) measures a CPU-bound workload with no gain. 12 cores is still 12 cores.
  • Unlimited concurrency exposes the next bottleneck. 10,000 virtual threads will happily ask a 10-connection Hikari pool for connections; every request now waits in getConnection() and throws SQLTransientConnectionException after 30 s. Gate scarce resources with a Semaphore or bounded pool and size timeouts deliberately.
  • Pinning. A virtual thread blocked inside synchronized (or a native frame) keeps its carrier. On JDK 21-23 this limits throughput if you hold monitors across I/O; JDK 24 (JEP 491) removes the synchronized case. Diagnose with -Djdk.tracePinnedThreads=full or the jdk.VirtualThreadPinned JFR event.
  • ThreadLocal-heavy libraries allocate per-virtual-thread state at a scale they were never designed for. Prefer scoped values (preview in 21) or pass state explicitly.
MVC or WebFlux? A decision framework instead of a religion

WebFlux (Reactor, Netty by default) uses a few event-loop threads and never blocks; any blocking call on the event loop stalls every request on it. For OrderService - blocking JDBC/JPA, blocking RestClient calls - WebFlux would force a rewrite of the data layer (R2DBC or wrapping blocking calls on a scheduler) for no throughput gain that virtual threads do not already give.

Decision flow
Choosing a concurrency model for an HTTP service (decision flow) Is the request mostly waiting on I/O? (DB, HTTP, queues) Spring MVC + virtual threads spring.threads.virtual.enabled MVC + platform threads size the pool from Little's Law CPU-bound? scale cores neither model helps yes (JDK 21+) yes, JDK 17 no Reach for WebFlux / reactive only if you need one of: streaming + end-to-end backpressure (SSE, large flows), a reactive-only driver or API, huge connection counts with tiny CPU per request, or a team fluent in Reactor. Not because "reactive is faster": for plain request/response it is not automatically faster.

Judgement framework, not measured. Reactive code wins on streaming and backpressure, not automatically on request/response latency.

QuestionSpring MVC + virtual threadsSpring MVC + platform threadsWebFlux
Programming modelPlain blocking codePlain blocking codeMono/Flux, operators
Concurrency limitMemory, downstream poolsPool sizeEvent loops (never block)
Blocking JDBC / JPAFineFineMust be offloaded or replaced (R2DBC)
Debugging, stack tracesNormalNormalAsync stack traces, harder
Streaming + backpressurePossible, manualPossible, manualFirst-class
Best fitI/O-bound CRUD services on Java 21+JDK 17 or CPU-heavyStreaming, gateways, reactive-only stacks
05
Chapter Five - Lab Three - Serialisation and Payload Size

Jackson Cost, DTOs and gzip

Every response has two costs: the CPU to produce its bytes (Jackson serialisation, optional gzip) and the time to move them. The cheapest byte is the one you never serialise.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

GET /customers/{id}/orders returns a list of full Order entities - the graph the ORM loaded: items, both addresses, internal notes and an audit trail. The mobile team complains about 130 KB responses; the profiler shows Jackson in the top frames of the flame graph.

Step 2 · Hypothesis

Serialisation CPU and bytes scale with the object graph, not with what the client uses. A slim DTO will cut both by an order of magnitude; gzip will cut bytes further but spends CPU.

Step 3 · Measurement

Payload.java (chapter 10) serialises 100 orders both ways with Jackson 2.17.2 (the version Boot 3.3.4 manages), gzips the bytes, then times 5,000 iterations after 3,000 warm-up iterations. Run twice because the machine was loaded:

shell
javac -cp $JACKSON -d out Payload.java java -cp out:$JACKSON Payload
Step 4 · Diagnosis
real output (run 2 of 2)
100 orders, full entity graph : 131,956 bytes gzip: 5,258 bytes (25.1x smaller) 100 orders, slim DTO : 8,891 bytes gzip: 373 bytes (23.8x smaller) Jackson serialise full graph : 706.6 us per response Jackson serialise slim DTO : 69.3 us per response gzip of the full-graph body : 1461.6 us per response transfer @ 1000 Mbit/s: raw 1.06 ms gzip 0.04 ms (gzip pays off if CPU 1.46 ms < saved 1.01 ms) transfer @ 100 Mbit/s: raw 10.56 ms gzip 0.42 ms (gzip pays off if CPU 1.46 ms < saved 10.14 ms) transfer @ 20 Mbit/s: raw 52.78 ms gzip 2.10 ms (gzip pays off if CPU 1.46 ms < saved 50.68 ms)

Run 1 on the same loaded machine gave 1,252 us / 88 us / 1,483 us: the absolute CPU numbers move by up to 2x with neighbour load, the ratios (about 10-15x between graph and DTO) do not. Note the surprising line: gzip CPU (about 1.5 ms) is larger than the whole Jackson serialisation (0.7-1.2 ms) for this body, and on a fast network it costs more than the transfer time it saves.

Bytes on the wire
Bytes for GET /customers/7/orders (100 orders) - measured full entity graph 131956 B graph + gzip 5258 B slim DTO 8891 B slim DTO + gzip 373 B Full graph: 4 items per order, two addresses, audit and notes fields. Slim DTO: id, status, createdAt, total, itemCount. Cutting fields shrinks the body 15x; gzip shrinks either body about 25x (repetitive keys compress well).

Measured with Jackson 2.17.2 and java.util.zip; synthetic but representative order data.

Step 5 · Optimization

Two separate changes, in order of payoff. First, return a projection:

Java - slim DTO and a projection that never loads the graph
public record OrderSummary(long id, String status, Instant createdAt, BigDecimal total, int itemCount) {} public interface OrderRepository extends JpaRepository<Order, Long> { // Spring Data projection: SELECT only these columns, no entity, no lazy items @Query("select new com.example.OrderSummary(o.id, o.status, o.createdAt, o.total, size(o.items)) " + "from Order o where o.customerId = :customerId order by o.createdAt desc") List<OrderSummary> summaries(long customerId, Pageable page); }

Second, decide on compression where it pays - typically at the CDN / load balancer edge for clients on slow links, not in every origin pod:

application.yml - gzip in the application (only if nothing upstream does it)
server: compression: enabled: true min-response-size: 2KB mime-types: application/json,text/html
Step 6 · Verification
Does gzip pay for itself? It depends on the link
Wire time vs CPU cost of gzip for the 132 KB body (one response) 1 Gbit/s LAN (same VPC) raw 1.06 ms gzip 0.04 ms transfer 100 Mbit/s raw 10.56 ms gzip 0.42 ms transfer 20 Mbit/s (mobile / WAN) raw 52.78 ms gzip 2.10 ms transfer dashed gold line = gzip CPU cost 1.46 ms (same scale) Measured gzip CPU cost for this body: about 1.46-1.48 ms (new GZIPOutputStream per call, loaded machine). On a 1 Gbit LAN it costs MORE CPU time (1.5 ms) than it saves on the wire (1.0 ms). On slow links it wins by 35x. Breakeven therefore depends on the link the client is on, not on the server alone.

Computed from the measured byte counts and measured gzip CPU time; transfer time = bytes x 8 / link speed, ignoring latency and TCP effects.

ChangeBytesJackson CPUVerdict
Return entity graph131,9560.7-1.25 msbaseline
Slim DTO projection8,891 (-93 %)0.07-0.09 ms (-90 %)always worth it
+ gzip on the graph5,258+1.5 ms CPUonly for slow clients
+ gzip on the DTO373a few tens of microseconds (not separately measured)cheap but small absolute saving
⚠ Production gotchas: serialisation and payload
  • Returning JPA entities. Besides the size, lazy associations trigger N+1 queries during serialisation when open-in-view is on, and bidirectional relations loop forever (JsonMappingException: Infinite recursion). Return DTOs.
  • Compressing twice. If CloudFront or the ALB already compresses, application gzip burns CPU for no gain; check the Content-Encoding header on the response the client really sees.
  • Creating a new ObjectMapper per call. Building one is expensive (module discovery, serializer caches). Inject the Boot-configured singleton.
  • Pretty-printing and WRITE_DATES_AS_TIMESTAMPS surprises inflate bytes; check the actual JSON once with curl -s ... | wc -c.
  • Streaming huge lists as one array. Paginate (keyset, see Database) instead of raising limits.
06
Chapter Six - Lab Four - TCP, TLS and Keep-Alive

What a Connection Costs - and the 40 ms Mystery

A fresh TCP + TLS connection costs several round trips before the first byte of your answer, plus CPU for cryptography. A kept-alive connection costs none. This lab measures the gap on loopback (where RTT is ~0 and so the numbers are conservative), then reproduces a famous production mystery: a mysterious 40 ms per request.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

OrderService calls the PaymentGateway once per order. The service team sees GET /orders/{id} at 12 ms p50, but a trace shows 8 ms of it is spent before the request leaves, in "connect". The calling code creates a new HTTP client (or sets Connection: close) per call.

Step 2 · Hypothesis

Connection setup (TCP + TLS) dominates small requests; reusing the connection removes it. Expect HTTPS to gain far more than plain HTTP because of the TLS handshake.

Step 3 · Measurement

ConnReuse.java starts a com.sun.net.httpserver server (HTTP or HTTPS with a self-signed RSA-2048 key from keytool) and sends 2,000 sequential GETs (500 warm-up) with HttpURLConnection, either with Connection: close or with keep-alive. Three repetitions of each; medians reported.

shell
keytool -genkeypair -alias t -keyalg RSA -keysize 2048 -validity 30 -storetype PKCS12 \ -keystore keystore.p12 -storepass changeit -dname "CN=localhost" -ext san=ip:127.0.0.1 javac -d out ConnReuse.java java -Dsun.net.httpserver.nodelay=true -cp out ConnReuse https newconn 2000 java -Dsun.net.httpserver.nodelay=true -cp out ConnReuse https keepalive 2000
Step 4 · Diagnosis
real output (one of the three repetitions each)
http new-conn n=2000 avg= 1275 us p50= 1040 p95= 2707 p99= 4536 throughput= 784 req/s http keepalive n=2000 avg= 569 us p50= 444 p95= 1122 p99= 1804 throughput= 1757 req/s https new-conn n=2000 avg= 5993 us p50= 5524 p95= 10742 p99= 15906 throughput= 167 req/s https keepalive n=2000 avg= 948 us p50= 762 p95= 2024 p99= 4234 throughput= 1055 req/s
Mode (median of 3 runs)p50p95p99Gain from keep-alive at p50
HTTP new connection1,040 us2,707 us4,536 us-
HTTP keep-alive444 us1,122 us3,518 us2.3x
HTTPS new connection5,524 us10,742 us15,906 us-
HTTPS keep-alive762 us2,024 us4,234 us7.2x

On loopback a new HTTPS connection costs about 5 ms of handshake that a reused one does not. On a real network add 2 round trips of RTT (cross-AZ: very roughly 1 ms each; across regions tens of ms - illustrative) on top.

Handshake cost in round trips
Round trips before the first byte of an answer (RTT = one network round trip) client server New HTTPS connection (TLS 1.3, 2 RTT before response data begins) SYN SYN-ACK ACK + ClientHello ServerHello, cert, Finished Finished + GET /orders/42 200 OK + body Reused keep-alive connection (0 handshake RTT) GET /orders/42 200 OK + body Fresh TLS 1.3 call: 3 RTT (TCP 1 + TLS 1 + request 1). Reused connection: 1 RTT. TLS 1.2 adds one more RTT; session resumption / 0-RTT claw some back (with caveats). Structure per the TCP and TLS 1.3 handshakes (RFC 8446); times scale with RTT.
Measured: new connection vs keep-alive
Sequential requests to a local server, median of 3 runs (p50 latency, microseconds) HTTP, new connection 1040 us HTTP, keep-alive 444 us HTTPS, new connection 5524 us HTTPS, keep-alive 762 us p99 (median of 3): HTTP new 4,536 / keep-alive 3,518; HTTPS new 15,906 / keep-alive 4,234 us. HTTPS handshake over loopback: +4.8 ms of CPU-heavy cryptography (RSA 2048 cert), RTT is about zero here. On a real network each of the 2-3 handshake round trips adds ITS RTT on top of this CPU cost. Loopback hides RTT entirely, so these numbers UNDER-state the benefit of keep-alive. Noise: shared machine, load average 23. Single-threaded sequential client; JDK HttpsURLConnection may resume TLS sessions.

Measured on loopback, shared 12-core machine, median of 3 runs of 2,000 sequential requests.

Step 5 · Optimization

Reuse connections: one long-lived, pooled client per downstream (chapter 7), never a client per call. The JDK HttpClient and Apache HttpClient pool by default; HttpURLConnection keeps connections alive unless http.keepAlive=false or a close header is sent.

Step 6 · Verification

Throughput of the single-threaded client rose from 784 to 1,757 req/s (HTTP) and from 167 to 1,055 req/s (HTTPS). The next section is the surprise that appeared while measuring.

Side quest: the 43 ms keep-alive request

Our very first keep-alive run, with the JDK server in its default configuration, reported 43.7 ms average per request - 40x worse than opening a new connection:

real output - default settings (200 requests)
http keepalive n=200 avg= 43734 us p50= 43036 p95= 46988 p99= 57544 throughput= 23 req/s
real output - same code, one JVM flag on the server
http keepalive n=200 avg= 1733 us p50= 1568 p95= 3447 p99= 4719 throughput= 577 req/s (-Dsun.net.httpserver.nodelay=true)

A constant ~40 ms is the signature of Nagle's algorithm interacting with delayed ACKs: the server writes the response in two small segments (headers, then body); Nagle refuses to send the second until the first is acknowledged, and the client delays its ACK. Setting TCP_NODELAY (the flag above does it for this server) removes the wait. Tomcat/Netty set TCP_NODELAY by default, but any hand-rolled socket client, proxy or legacy server can bring the stall back, and the symptom - "exactly 40 ms, only on reused connections" - looks like nothing in the application profiler.

Nagle + delayed ACK
The 40 ms stall: Nagle + delayed ACK on a keep-alive connection client server (no TCP_NODELAY) response headers (small segment, sent at once) server has more bytes (the body) but an un-ACKed small segment is in flight: Nagle holds the body until the ACK arrives client delays its ACK, hoping to piggyback (delayed ACK, up to 40 ms) ~40 ms of nothing happening delayed ACK finally sent (or request #2 piggybacks it) body released Measured here: 43.7 ms average per request with default settings; 1.7 ms after setting sun.net.httpserver.nodelay=true.

Mechanism diagram; the 43 ms figure is measured here, the cause (Nagle + delayed ACK) is the standard explanation and was confirmed by the flag fixing it.

⚠ Production gotchas: connection reuse
  • Client per call. HttpClient.newHttpClient() / new RestTemplate() inside a method creates a pool that is thrown away; sockets go to TIME_WAIT, ephemeral ports run out (Cannot assign requested address) at a few tens of thousands of calls per minute to one destination.
  • HTTP/2 helps differently. One TCP+TLS connection multiplexes many streams, removing per-request connection setup and head-of-line blocking at the HTTP layer (TCP-level HOL remains). It does not remove the need to keep the connection alive. Apache HttpClient 5 and JDK HttpClient support it; test with your real LB.
  • Measuring on localhost only. It hides RTT. Measure from the real subnet (same AZ, cross-AZ) before deciding that a handshake "does not matter".
  • TLS termination at the ALB means pod-to-ALB handshakes are not your problem; pod-to-downstream (PaymentGateway over HTTPS) is.
07
Chapter Seven - Lab Five - HTTP Clients, Pools, Timeouts and DNS

Pooling Outbound Calls Without Hanging

Chapter 6 said "pool your connections". This chapter is how: the client libraries, the knobs that matter, the timeouts that prevent a slow dependency from taking down OrderService, and the DNS cache that silently pins you to a dead IP.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

p99 of POST /orders is 1.4 s, but the PaymentGateway team swears their service answers in 40 ms. A distributed trace shows the gap inside OrderService, before the request leaves.

Step 2 · Hypothesis

The HTTP client's connection pool is smaller than the concurrency that wants it, so requests queue for a pooled connection (client-side Little's Law again: pool_size / call_time = max calls per second to that host).

Step 3 · Measurement

Client-side metrics: Micrometer exposes Apache HttpClient pool stats via httpcomponents.httpclient.pool.total.max, .connections (state=leased/available) and .pending when you bind the pool manager (PoolingHttpClientConnectionManagerMetricsBinder). Without those, a thread dump during the slow period shows many threads in PoolingHttpClientConnectionManager.lease / Future.get:

shell
jcmd <pid> Thread.print | grep -c 'PoolingHttpClientConnectionManager' # many = pool exhaustion jcmd <pid> Thread.print | grep -A12 'http-nio-8080-exec-17' # what one worker is waiting on

The tell-tale: workers parked (WAITING), not RUNNABLE, with a client-pool frame on top (see the thread-dump reading guide on the Profiling page).

Step 4 · Diagnosis
Pool smaller than demand
Client connection pool: per-route limit smaller than demand OrderService 200 request threads all want PaymentGW HTTP client pool maxPerRoute = 20 (your setting) 180 callers wait PaymentGateway one host = one "route" Symptom: downstream latency in the PaymentGateway dashboard is 40 ms; in OrderService traces the same call takes 900 ms. The extra 860 ms is time waiting for a pooled connection. Hits the connection-request timeout, not the read timeout. Apache HttpClient defaults are small (5 per route / 25 total in 5.x; verify). Set both explicitly.

Mechanism illustration with example numbers, not a measurement.

Little's Law applied to the pool: with 20 connections and a 40 ms call, the ceiling is 20 / 0.040 = 500 calls/s. Offered load of 1,000 calls/s (200 threads x 5 calls/s) means half the calls wait, and the wait grows until a timeout fires.

Step 5 · Optimization

Size the pool from the same formula, set every timeout, reuse one client bean, and keep it bounded:

Java - one pooled RestClient (Spring Framework 6.1+, Apache HttpClient 5)
@Configuration class PaymentClientConfig { @Bean(destroyMethod = "close") CloseableHttpClient paymentHttpClient() { PoolingHttpClientConnectionManager pool = PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnTotal(200) .setMaxConnPerRoute(100) // PaymentGateway is one route .setDefaultConnectionConfig(ConnectionConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(300)) // TCP handshake .setSocketTimeout(Timeout.ofSeconds(2)) // read timeout .setTimeToLive(TimeValue.ofMinutes(5)) // recycle: follows DNS changes .build()) .build(); return HttpClients.custom() .setConnectionManager(pool) .setDefaultRequestConfig(RequestConfig.custom() .setConnectionRequestTimeout(Timeout.ofMilliseconds(200)) // wait for a pooled connection .setResponseTimeout(Timeout.ofSeconds(2)) .build()) .evictIdleConnections(TimeValue.ofSeconds(30)) // never reuse a connection the server may have closed .build(); } @Bean RestClient paymentClient(CloseableHttpClient http, RestClient.Builder builder) { return builder.baseUrl("https://payments.internal") .requestFactory(new HttpComponentsClientHttpRequestFactory(http)) .build(); } }

Spring Boot 3.4 added spring.http.client.* properties for connect/read timeouts on auto-configured clients; if you are on 3.3 (as this lab is) configure the factory in code as above.

Step 6 · Verification

Re-run the load test and watch httpcomponents.httpclient.pool.connections{state=leased} stay below pool.total.max, .pending near zero, and the OrderService trace gap disappear. Do not claim a number here without running it: pool tuning gains depend entirely on the downstream.

Four timeouts, one budget
The four timeouts of one outbound call (set ALL of them, none is "infinite" by accident) 1 pool wait connection-request timeout 2 connect TCP handshake timeout 3 response TLS + first byte timeout 4 read / socket gap between bytes timeout time, left to right: one logical call An OVERALL deadline (budget) must also exist: connect 500 ms + read 2 s is up to 2.5 s per attempt, times retries. Typical starting points for a same-region service (illustrative, derive yours from the p99 of the dependency): connection-request 200 ms | connect 300-500 ms | response/read 1.5 x downstream p99 | overall = caller's remaining budget Default in many clients is NO read timeout (java.net.HttpURLConnection) or 30-60 s: one hung dependency then holds a worker thread for a minute - pool exhaustion, then outage (the retry-storm story on the Distributed page). Names vary by client: Apache HttpClient 5 ConnectionConfig / RequestConfig; JDK HttpClient connectTimeout + request.timeout.

Conceptual; the starting values are illustrative.

Client comparison
ClientPooling defaultTimeouts to setNotes
JDK java.net.http.HttpClientPools per client; no hard cap by defaultconnectTimeout, per-request timeoutHTTP/2 by default when the server offers it. One shared instance.
Apache HttpClient 5Small pool (verify defaults); configure max total/routeconnect, socket, response, connection-requestMost knobs; evictIdleConnections matters behind LBs.
Spring RestClient (6.1+)Whatever the request factory poolsOn the factoryFluent replacement for RestTemplate; synchronous.
RestTemplateDefault SimpleClientHttpRequestFactory = HttpURLConnectionSet factory timeoutsMaintenance mode; default factory has no timeouts.
WebClient (Reactor Netty)Connection provider with max connections and pending acquire queueresponseTimeout, connect timeout, pendingAcquireTimeoutNeeds reactive style; do not block on it from the event loop.
DNS: the cache that pins you to a dead IP

The JVM caches successful DNS lookups. Without a security manager the default is 30 seconds (the networkaddress.cache.ttl security property in java.security); a value of -1 caches forever. Failed lookups are cached for 10 s by default (networkaddress.cache.negative.ttl). With AWS endpoints that rotate IPs (ELB, RDS/Aurora writer failover, ElastiCache), a long TTL means connections keep going to an old address after a failover.

how to set it
# in $JAVA_HOME/conf/security/java.security (or programmatically before the first lookup) networkaddress.cache.ttl=30 networkaddress.cache.negative.ttl=5 // Java, early in main(): java.security.Security.setProperty("networkaddress.cache.ttl", "30");

Pooled connections add a second layer: a kept-alive socket is not re-resolved at all until it is closed, so also bound connection lifetime (Hikari maxLifetime, Apache timeToLive) so the pool turns over and re-resolves. This is how a failover to a new RDS writer IP actually takes effect.

⚠ Production gotchas: clients and DNS
  • No timeouts. Add them first; they are the cheapest resilience feature (also see Caching & Resilience for retry/circuit breaker).
  • Pool larger than the downstream's capacity. A 500-connection pool to a service that handles 50 concurrent requests just moves the queue to their side and amplifies overload.
  • Stale connections. A connection the load balancer closed at 60 s idle shows up as NoHttpResponseException: ... failed to respond or Connection reset on the first reuse; cure with idle eviction below the LB timeout (chapter 8).
  • Retrying non-idempotent POSTs blindly. A timeout does not mean the server did not process the request; use idempotency keys.
08
Chapter Eight - Lab Six - The Load Balancer and the AWS Network

ALB Idle Timeouts, Keep-Alive and Cross-AZ

Everything so far ran on a laptop. In AWS there are extra hops that have their own timeouts, and the single most common "mysterious 502" in Spring-on-ALB deployments is a keep-alive mismatch we can reproduce exactly. The AWS-side figures in this chapter are documented defaults and general guidance; nothing here was measured against real AWS services.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

After deploying OrderService behind an ALB, users see sporadic 502 Bad Gateway - maybe 1 in 2,000 requests - with no error in the application log and no 5xx in the target group. The ALB access log shows elb_status_code=502 and target_status_code=-.

Step 2 · Hypothesis

The ALB reuses a kept-alive connection that Tomcat has already closed. ALB's idle timeout defaults to 60 seconds; Tomcat's keep-alive timeout (falling back to connectionTimeout) is shorter, so there is a window where the ALB sends a request into a half-closed connection. The ALB (the "client" of your pod) sees the connection reset and answers the user with 502.

Step 3 · Measurement

Reproduce the mechanism with real Tomcat 10.1.30 and a raw socket acting as the load balancer. The server's keep-alive timeout is 2 s; the "ALB" idles for 3 s then reuses the connection:

real output - TomcatLab idle
t=0.0s request 1 -> HTTP/1.1 200 t=3.0s reusing the same TCP connection for request 2 ... read() returned -1 (EOF: server already closed the socket)

read() returned -1 is the client-side view of the server having closed the idle connection: exactly what the ALB experiences when it reuses a connection Tomcat closed. In production the ALB observes it as a reset/EOF and answers the user with 502.

Step 4 · Diagnosis
The mismatch, step by step
ALB keep-alive mismatch: why a healthy app returns 502s ALB (client of your pod) Tomcat / pod t=0 s request 1 on connection C 200 OK (connection C stays open, idle) idle... X Tomcat keepAliveTimeout fires at t=20 s: server sends FIN, closes C ALB idle timeout is 60 s, so it still believes C is usable t=30 s request 2 sent on connection C RST / connection closed ALB returns 502 Bad Gateway to the user HTTPCode_ELB_502_Count rises; target 5xx stays 0 Fix: server keep-alive timeout LONGER than the ALB idle timeout (e.g. 65-75 s vs 60 s).

Mechanism reproduced in the lab; the 60 s ALB idle timeout is the documented default, configurable on the load balancer. The failure rate depends on traffic patterns.

Diagnostic signature: CloudWatch HTTPCode_ELB_502_Count non-zero while HTTPCode_Target_5XX_Count is zero; ALB access log target_status_code is -; occurrences cluster on low-traffic connections that sat idle between 20 s and 60 s.

Step 5 · Optimization

Make the server the last one to close an idle connection: its keep-alive timeout must exceed the ALB idle timeout (60 s by default; raise this together with the target's).

application.yml (Spring Boot 3.2+ property names)
server: tomcat: keep-alive-timeout: 75s # > ALB idle timeout (60 s default) max-keep-alive-requests: 10000 # default is much lower; a request cap closes connections the LB did not expect connection-timeout: 20s

For the same reason outbound: your own pooled clients should evict idle connections before the downstream's timeout (Apache evictIdleConnections below the far side's idle limit). NLB behaves differently (it passes TCP through; documented idle timeout for TCP flows is 350 seconds - check the current doc) and you may need TCP keep-alive probes from the application to keep flows alive.

Step 6 · Verification

After the change, 502 count returns to zero while the target-group metrics are unchanged. We could not run this against AWS here; in a real rollout do it during a quiet hour and watch HTTPCode_ELB_502_Count and TargetConnectionErrorCount for a full idle cycle (> 60 s).

Cross-AZ traffic
Cross-AZ traffic: more round trips AND dollars (illustrative topology) AZ a AZ b ALB node OrderService pod Aurora writer Redis primary ALB node OrderService pod Aurora reader Redis replica Red dashed: a pod in AZ b calling the writer in AZ a - extra RTT per call and cross-AZ data transfer charges. Keep chatty paths AZ-local (reads from same-AZ reader / replica); one RTT x N+1 queries = N+1 x cross-AZ RTT.

Topology illustration, not a measurement.

Other AWS-network levers worth knowing
TopicWhat to knowStatus
Cross-AZ latencySame-AZ round trips are typically sub-millisecond; cross-AZ adds on the order of a millisecond or a few; each extra hop multiplies with chattiness (N+1).general guidance - measure with ping/trace in your VPC
Cross-AZ data transferCharged per GB in each direction between AZs. Chatty services between AZs can cost real money.check current pricing
ALB HTTP/2 and keep-aliveALB supports HTTP/2 to clients; to targets it uses HTTP/1.1 unless the target group is configured otherwise.verify for your config
ALB slow start / least-outstanding-requestsLB algorithm "least outstanding requests" avoids slamming a cold or slow target; slow start ramps new targets.see Scaling page
Connection draining (deregistration delay)Default 300 s: how long in-flight requests get after a target deregisters. Align with Spring graceful shutdown.documented default
NAT gatewayOutbound calls to the internet via NAT pay data-processing fees and share a port range (about 55k ports per destination).general guidance
⚠ Production gotchas: load balancers
  • Matching timeouts exactly. If both are 60 s the race still happens; the server must be strictly longer.
  • Service meshes / sidecars add their own idle timeouts (Envoy defaults vary). Walk the entire chain; the shortest idle timeout is the one that bites.
  • Forgetting health checks. A health-check endpoint that does a DB query can take the whole fleet out of rotation when the DB is slow (see Actuator for liveness vs readiness).
  • Thinking 502 is "the app crashed". It is the LB failing to get a valid response; 503 = no healthy targets; 504 = target did not answer within idle timeout. Different causes.
09
Chapter Nine - Quick Wins and Honest Limits

Startup and Footprint (Pointer Chapter)

Startup time and memory footprint matter when instances scale out under load or on scale-to-zero platforms. The deep treatment (JIT warm-up curve, AppCDS, native image, CRaC) is on 04 JIT, Startup & Native Image. This chapter is the Spring-specific part - what to try, and what we did not measure here.

TechniqueWhat it changesCost / caveatMeasured here?
spring.main.lazy-initialization=trueBeans created on first use, not at startupStartup drops, first request slower, and misconfigured beans fail late (in production, not at boot)no
Trim the classpath / auto-configurationsFewer beans and classes to loadUse the conditions report (--debug) to see what is activeno
Spring AOT + -Dspring.aot.enabled=trueMoves bean definition processing to build timeBuild step; reduced reflection; required for nativeno
AppCDS / class data sharingJVM maps pre-parsed classesArchive must match JDK and classpathsee page 04
Virtual-thread startup costNone noticeable-no
Tomcat vs Jetty vs UndertowDifferent footprintRarely the bottleneck; measure before swappingno
Honest note: We did not run Spring Boot startup benchmarks on this page. The numbers people quote (seconds saved by lazy init) depend entirely on how many beans, how much classpath scanning, and how fast the disk is. Measure your own with -Xlog:startuptime-style logging or the application.started.time actuator metric before and after one change at a time.
⚠ Production gotchas: startup
  • Lazy init hiding failures. A bean that would have failed at boot now fails on the first customer request. Keep eager init for anything that opens connections you rely on.
  • Readiness before warm-up. A pod marked ready at second 8 serves its first requests on a cold JIT with empty caches; p99 spikes for the first minute (see Scaling).
  • Slow startup + aggressive liveness probes. Kubernetes kills the pod before it finishes booting - use a startupProbe.
10
Chapter Ten - Try It Yourself

The Complete Programs

Everything on this page came from these programs, run in a scratch directory outside the repository. Prerequisites: Java 21, Maven artifacts for Tomcat embed 10.1.30 (tomcat-embed-core, plus jakarta.annotation-api 2.1.1) and Jackson 2.17.x - for example copy them from ~/.m2 after building any Boot 3.3.x project, or use mvn dependency:copy-dependencies.

shell - classpath and build
TOMCAT=~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/10.1.30/tomcat-embed-core-10.1.30.jar ANN=~/.m2/repository/jakarta/annotation/jakarta.annotation-api/2.1.1/jakarta.annotation-api-2.1.1.jar JACKSON=~/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.17.2/jackson-core-2.17.2.jar:...jackson-databind-2.17.2.jar:...jackson-annotations-2.17.2.jar javac -cp $TOMCAT:$ANN -d out TomcatLab.java javac -cp $JACKSON -d out Payload.java javac -d out ConnReuse.java
TomcatLab.java - thread pool, virtual threads and the keep-alive mismatch
Java - ran in chapters 3, 4 and 8
import jakarta.servlet.http.*; import org.apache.catalina.Context; import org.apache.catalina.connector.Connector; import org.apache.catalina.startup.Tomcat; import org.apache.coyote.http11.Http11NioProtocol; import java.io.*; import java.net.*; import java.net.http.*; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** * Lab 7: embedded Tomcat 10.1 (the one Spring Boot 3.3 embeds) with a handler that blocks 50 ms * (a downstream PaymentGateway call). Vary the request-thread pool, drive it with a closed-loop client. * java TomcatLab threads 10 400 8 -> maxThreads=10, 400 concurrent clients, 8 s * java TomcatLab virtual 0 400 8 -> virtual-thread executor * java TomcatLab idle -> keep-alive timeout mismatch demo (raw socket) */ public class TomcatLab { static final byte[] BODY = "{\"id\":42,\"status\":\"PAID\"}".getBytes(StandardCharsets.UTF_8); static Tomcat start(int port, int maxThreads, boolean virtual, int keepAliveMs) throws Exception { Tomcat t = new Tomcat(); t.setBaseDir(System.getProperty("java.io.tmpdir") + "/tclab"); Connector c = new Connector("org.apache.coyote.http11.Http11NioProtocol"); c.setPort(port); Http11NioProtocol p = (Http11NioProtocol) c.getProtocolHandler(); if (virtual) p.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); else p.setMaxThreads(maxThreads); if (keepAliveMs > 0) p.setKeepAliveTimeout(keepAliveMs); t.setConnector(c); Context ctx = t.addContext("", new File(".").getAbsolutePath()); Tomcat.addServlet(ctx, "orders", new HttpServlet() { @Override protected void doGet(HttpServletRequest rq, HttpServletResponse rs) throws IOException { try { Thread.sleep(50); } catch (InterruptedException e) { } // downstream call rs.setContentType("application/json"); rs.setContentLength(BODY.length); rs.getOutputStream().write(BODY); } }); ctx.addServletMappingDecoded("/*", "orders"); t.start(); return t; } public static void main(String[] a) throws Exception { String mode = a[0]; if (mode.equals("idle")) { idle(); return; } int threads = Integer.parseInt(a[1]), clients = Integer.parseInt(a[2]), secs = Integer.parseInt(a[3]); boolean virtual = mode.equals("virtual"); Tomcat t = start(18080, threads, virtual, 0); HttpClient hc = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(5)).build(); HttpRequest rq = HttpRequest.newBuilder(URI.create("http://127.0.0.1:18080/orders/42")) .timeout(Duration.ofSeconds(30)).build(); long warmEnd = System.nanoTime() + 2_000_000_000L; long end = warmEnd + secs * 1_000_000_000L; ConcurrentLinkedQueue<Long> lats = new ConcurrentLinkedQueue<>(); AtomicLong errors = new AtomicLong(); try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < clients; i++) ex.submit(() -> { while (System.nanoTime() < end) { long t0 = System.nanoTime(); try { hc.send(rq, HttpResponse.BodyHandlers.discarding()); if (t0 >= warmEnd) lats.add(System.nanoTime() - t0); } catch (Exception e) { errors.incrementAndGet(); } } return null; }); } long[] l = lats.stream().mapToLong(Long::longValue).sorted().toArray(); double avg = Arrays.stream(l).average().orElse(0) / 1e6; System.out.printf("%-14s clients=%d req=%6d throughput=%6.0f req/s avg=%6.1f ms p50=%6.1f p95=%6.1f p99=%6.1f errors=%d%n", virtual ? "virtual-threads" : "maxThreads=" + threads, clients, l.length, l.length / (double) secs, avg, l[l.length / 2] / 1e6, l[(int) (l.length * .95)] / 1e6, l[(int) (l.length * .99)] / 1e6, errors.get()); t.stop(); t.destroy(); System.exit(0); } /** The ALB-502 mechanism: the server closes an idle keep-alive connection; the client (the load balancer) reuses it. */ static void idle() throws Exception { Tomcat t = start(18081, 10, false, 2000); // server keep-alive timeout 2 s try (Socket s = new Socket("127.0.0.1", 18081)) { s.setSoTimeout(5000); String req = "GET /orders/42 HTTP/1.1\r\nHost: x\r\n\r\n"; OutputStream o = s.getOutputStream(); InputStream in = s.getInputStream(); o.write(req.getBytes()); o.flush(); byte[] buf = new byte[512]; int n = in.read(buf); System.out.println("t=0.0s request 1 -> " + new String(buf, 0, n).lines().findFirst().get()); Thread.sleep(3000); // idle longer than the server's 2 s keep-alive System.out.println("t=3.0s reusing the same TCP connection for request 2 ..."); try { o.write(req.getBytes()); o.flush(); n = in.read(buf); System.out.println(" read() returned " + n + (n == -1 ? " (EOF: server already closed the socket)" : "")); } catch (IOException e) { System.out.println(" IOException: " + e); } } t.stop(); t.destroy(); System.exit(0); } }
ConnReuse.java - new connection vs keep-alive, HTTP and HTTPS
Java - ran in chapter 6
import com.sun.net.httpserver.*; import javax.net.ssl.*; import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.util.*; import java.util.concurrent.*; /** Lab 7: cost of a new connection vs a pooled keep-alive connection, plain HTTP and TLS, on localhost. */ public class ConnReuse { static final String BODY = "{\"id\":42,\"customerId\":7,\"status\":\"PAID\",\"items\":[{\"sku\":\"A-100\",\"qty\":2},{\"sku\":\"B-200\",\"qty\":1}]}"; public static void main(String[] a) throws Exception { String mode = a[0]; // http | https boolean keepAlive = a[1].equals("keepalive"); int n = Integer.parseInt(a[2]); boolean tls = mode.equals("https"); SSLContext ctx = null; if (tls) { KeyStore ks = KeyStore.getInstance("PKCS12"); try (InputStream in = new FileInputStream("keystore.p12")) { ks.load(in, "changeit".toCharArray()); } KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); kmf.init(ks, "changeit".toCharArray()); TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509"); tmf.init(ks); ctx = SSLContext.getInstance("TLS"); ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); } HttpServer server; if (tls) { HttpsServer hs = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); hs.setHttpsConfigurator(new HttpsConfigurator(ctx)); server = hs; } else { server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); } server.createContext("/orders/42", ex -> { byte[] b = BODY.getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.sendResponseHeaders(200, b.length); try (OutputStream os = ex.getResponseBody()) { os.write(b); } }); server.setExecutor(Executors.newFixedThreadPool(4)); server.start(); int port = server.getAddress().getPort(); String url = (tls ? "https" : "http") + "://127.0.0.1:" + port + "/orders/42"; if (tls) HttpsURLConnection.setDefaultSSLSocketFactory(ctx.getSocketFactory()); HttpsURLConnection.setDefaultHostnameVerifier((h, s) -> true); int warm = Integer.getInteger("warm", 500); long[] lat = new long[n]; for (int i = 0; i < warm + n; i++) { long t0 = System.nanoTime(); HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); if (!keepAlive) c.setRequestProperty("Connection", "close"); try (InputStream in = c.getInputStream()) { in.readAllBytes(); } if (!keepAlive) c.disconnect(); long dt = System.nanoTime() - t0; if (i >= warm) lat[i - warm] = dt; } Arrays.sort(lat); double sum = 0; for (long l : lat) sum += l; System.out.printf("%-6s %-9s n=%d avg=%6.0f us p50=%6.0f p95=%6.0f p99=%6.0f throughput=%6.0f req/s%n", mode, keepAlive ? "keepalive" : "new-conn", n, sum / n / 1e3, lat[n / 2] / 1e3, lat[(int) (n * .95)] / 1e3, lat[(int) (n * .99)] / 1e3, n / (sum / 1e9)); server.stop(0); System.exit(0); } }
Payload.java - serialisation bytes, CPU and gzip
Java - ran in chapter 5
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.annotation.JsonInclude; import java.io.ByteArrayOutputStream; import java.util.*; import java.util.zip.GZIPOutputStream; /** Lab 7: what does the JSON for GET /customers/{id}/orders cost in bytes and CPU? */ public class Payload { public record Item(String sku, String productName, String description, int qty, double unitPrice, String currency) {} public record Address(String line1, String line2, String city, String postcode, String country) {} public record Order(long id, long customerId, String status, String createdAt, String updatedAt, Address shipping, Address billing, List<Item> items, String internalNotes, String auditTrail) {} public record SlimOrder(long id, String status, String createdAt, double total, int itemCount) {} static Order order(long i) { Address a = new Address("12 Example Street", "Apartment " + (i % 40), "Springfield", "1234" + (i % 9), "US"); List<Item> items = new ArrayList<>(); for (int k = 0; k < 4; k++) items.add(new Item("SKU-" + (1000 + i * 7 + k), "Product name number " + (i * 7 + k), "A reasonably long marketing description of the product that nobody reads on a list screen " + k, 1 + k, 9.99 + k, "USD")); return new Order(i, 7, "PAID", "2026-10-30T10:15:30Z", "2026-10-30T10:16:02Z", a, a, items, "customer called twice about delivery window", "created-by=checkout;updated-by=payments;rev=" + i); } static long gz(byte[] b) throws Exception { ByteArrayOutputStream bo = new ByteArrayOutputStream(); try (GZIPOutputStream g = new GZIPOutputStream(bo)) { g.write(b); } return bo.size(); } public static void main(String[] args) throws Exception { ObjectMapper om = new ObjectMapper(); List<Order> full = new ArrayList<>(); List<SlimOrder> slim = new ArrayList<>(); for (int i = 0; i < 100; i++) { Order o = order(i); full.add(o); double total = 0; for (Item it : o.items()) total += it.qty() * it.unitPrice(); slim.add(new SlimOrder(o.id(), o.status(), o.createdAt(), total, o.items().size())); } byte[] fullJson = om.writeValueAsBytes(full); byte[] slimJson = om.writeValueAsBytes(slim); System.out.printf("100 orders, full entity graph : %,8d bytes gzip: %,7d bytes (%.1fx smaller)%n", fullJson.length, gz(fullJson), fullJson.length / (double) gz(fullJson)); System.out.printf("100 orders, slim DTO : %,8d bytes gzip: %,7d bytes (%.1fx smaller)%n", slimJson.length, gz(slimJson), slimJson.length / (double) gz(slimJson)); // CPU cost: warm up, then time serialisation and gzip separately int warm = 3000, runs = 5000; for (int i = 0; i < warm; i++) { om.writeValueAsBytes(full); om.writeValueAsBytes(slim); gz(fullJson); } long t0 = System.nanoTime(); for (int i = 0; i < runs; i++) om.writeValueAsBytes(full); double fullUs = (System.nanoTime() - t0) / 1e3 / runs; t0 = System.nanoTime(); for (int i = 0; i < runs; i++) om.writeValueAsBytes(slim); double slimUs = (System.nanoTime() - t0) / 1e3 / runs; t0 = System.nanoTime(); for (int i = 0; i < runs; i++) gz(fullJson); double gzUs = (System.nanoTime() - t0) / 1e3 / runs; System.out.printf("Jackson serialise full graph : %7.1f us per response%n", fullUs); System.out.printf("Jackson serialise slim DTO : %7.1f us per response%n", slimUs); System.out.printf("gzip of the full-graph body : %7.1f us per response%n", gzUs); // Network time saved at different link speeds (transfer only, ignoring latency) long raw = fullJson.length, zipped = gz(fullJson); for (double mbit : new double[]{1000, 100, 20}) { System.out.printf("transfer @ %5.0f Mbit/s: raw %7.2f ms gzip %6.2f ms (gzip pays off if CPU %.2f ms < saved %.2f ms)%n", mbit, raw * 8 / (mbit * 1e3), zipped * 8 / (mbit * 1e3), gzUs / 1e3, (raw - zipped) * 8 / (mbit * 1e3)); } } }
Make it yours: Change the handler sleep (50 ms) to your real downstream p50, the client count to your real peak concurrency, and re-run chapter 3 to find your thread-pool knee. Repeat chapter 6 against a host in another AZ. Always repeat each run several times and keep the median - these runs were taken on a busy shared machine and the spread between repetitions was often 2x.
11
Chapter Eleven - Interview Angles

Questions You Should Be Able to Answer

Scenario-style questions on this topic, with the answer an interviewer wants.

  • Q: Latency is 2 s but CPU is 10 % and the downstream says it answers in 50 ms. Where do you look first?
    Request queueing: Tomcat worker pool exhausted or a client/DB pool exhausted. Compare client-side latency with http.server.requests, check tomcat.threads.busy vs max and a thread dump for parked workers. Apply Little's Law: concurrency = throughput × latency.
  • Q: How do you size server.tomcat.threads.max?
    threads ≈ target throughput × time a request holds a thread, plus headroom, bounded by the narrowest downstream pool. Bigger is not better: it just moves the queue to the DB pool or the downstream.
  • Q: What does spring.threads.virtual.enabled=true do and what does it NOT do?
    Boot 3.2+/Java 21+: Tomcat request handling and the task executor run on virtual threads, so concurrency is no longer capped by a pool. It does not speed up CPU-bound work, does not remove DB pool limits, and can be hurt by pinning (synchronized; JEP 491 in JDK 24 fixes the synchronized case).
  • Q: Is WebFlux faster than MVC?
    Not automatically. It scales connections with few threads and gives backpressure/streaming, but needs non-blocking end to end. For blocking JDBC CRUD, MVC with virtual threads gives similar concurrency with simpler code.
  • Q: Users get occasional 502s behind an ALB with no app errors. Why?
    Keep-alive mismatch: the app closes idle connections before the ALB's 60 s idle timeout, so the ALB reuses a dead connection. Make the app's keep-alive timeout longer than the ALB idle timeout. Signature: HTTPCode_ELB_502_Count > 0 with zero target 5xx.
  • Q: Why do you reuse HTTP clients and connections?
    Each new connection costs TCP (1 RTT) + TLS (1-2 RTT) + CPU for crypto; we measured about 5 ms extra on loopback for HTTPS and 7x worse p50 vs keep-alive. Also avoids ephemeral-port exhaustion and TIME_WAIT pile-up.
  • Q: Which timeouts must every HTTP client have?
    Connection-request (pool wait), connect, response/read, plus an overall deadline from the caller's remaining budget. No timeout = a hung dependency holds a worker thread for minutes.
  • Q: A response is 130 KB and clients are slow. What do you try?
    First cut the payload (DTO projection, pagination): we measured 15x fewer bytes and 10x less Jackson CPU. Then compress at the edge. Application gzip burned more CPU than it saved on a fast LAN in our test.
  • Q: What is the 40 ms latency on keep-alive requests?
    Nagle's algorithm plus delayed ACK: small writes held until an ACK that the peer delays up to 40 ms. Set TCP_NODELAY on the sender. We reproduced 43.7 ms vs 1.7 ms with the flag.
  • Q: How does DNS caching bite you on AWS?
    JVM caches lookups (30 s default without a security manager; -1 = forever), and pooled connections are never re-resolved. After an RDS/Aurora failover or ELB IP change you keep hitting the old address; set a finite networkaddress.cache.ttl and bound connection lifetime.
12
Chapter Twelve - Summary

Cheat Sheet and Recap

Symptom to knob cheat sheet
SymptomLikely causeConfirm withFix
High latency, low CPU, downstream is fastRequest pool exhaustedtomcat.threads.busy = max; thread dumpMore threads (bounded) or virtual threads
Client-side latency >> server metricQueueing before the workerLB TargetResponseTime vs http.server.requestsSame as above; shed load
Slow only on first call / after idleNew TCP/TLS connectionTrace "connect" spanPooled client, keep-alive, eviction
Constant 40 ms on reused connectionsNagle + delayed ACKPacket capture, flag testTCP_NODELAY
Sporadic 502, target 5xx = 0App keep-alive < ALB idleALB access log target_status_code=-App timeout > 60 s
Outbound call slow in app, fast in downstreamClient pool too smallPool metrics, thread dumpSize from Little's Law, set timeouts
Big responses, CPU in JacksonEntity graphs / no DTOProfile + response sizeProjection, pagination
Traffic goes to a dead IP after failoverDNS / connection cachingResolve vs connected IPFinite DNS TTL, max connection lifetime
∑ Spring, HTTP & Network Performance - what to remember
  • Little's Law is the whole story of pools: in-flight = rate × time. Measured: 10 threads / 50 ms = 200 req/s and 2 s latency at 400 clients; 200 threads reached 3,874 req/s.
  • Virtual threads remove the request-pool limit (5,950 req/s in our run) and expose the next limit - size DB and downstream pools deliberately.
  • MVC vs WebFlux: choose reactive for streaming/backpressure, not for assumed speed.
  • Shrink the payload before you compress it. DTO: 15x fewer bytes; gzip: 25x - but gzip cost more CPU than the wire time saved on a fast LAN.
  • Reuse connections. HTTPS new-connection p50 was 5.5 ms vs 0.76 ms kept-alive, on loopback.
  • Set all four timeouts and bound connection lifetime; evict idle connections below the far side's timeout.
  • ALB idle timeout (60 s) < your keep-alive timeout or you get intermittent 502s.
  • All AWS-side figures here are documented defaults or illustrative; nothing was measured against real AWS.
Related pages: