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.
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.
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.
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:
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.
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.)
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.
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.
Change exactly one variable: server.tomcat.threads.max. Then, separately, replace the pool with virtual threads. Chapter 3 and 4 do both and compare.
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.
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.
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.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.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).Structure diagram of the real class flow; not a measured profile.
Structure diagram. Defaults per Tomcat 10.1 / Boot 3 documentation.
| Property (application.yml) | What it controls | Notes |
|---|---|---|
server.tomcat.threads.max | Size of the worker pool | Default 200. Platform threads: each costs roughly a thread stack (about 1 MB reserved) plus scheduler overhead. |
server.tomcat.threads.min-spare | Workers kept alive when idle | Default 10. Raise if bursts arrive faster than threads spawn. |
server.tomcat.accept-count | Backlog of pending connections once max-connections is reached | Default 100. Beyond it the client sees connection refused / timeouts. |
server.tomcat.max-connections | Sockets Tomcat will hold open (NIO) | Default 8192. Must exceed your LB connection fan-in plus idle keep-alives. |
server.tomcat.connection-timeout | Time to wait for the request line / data on a new connection | Interacts with keep-alive timeout; see chapter 8. |
spring.threads.virtual.enabled | Use virtual threads for request handling and async execution | Boot 3.2+, needs Java 21+. |
server.compression.enabled | gzip responses above a size threshold | Trades CPU for bytes; see chapter 5. |
spring.jpa.open-in-view | Keep the DB connection for the whole request (default true) | Hidden pool hog: see Database. |
DispatcherServlet resolves arguments, how converters are chosen) is explained in REST Controllers & the MVC Request Path. This page only asks what each stage costs.
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.
Throughput stalls at 198 req/s and every response takes 2 seconds, with 400 clients and almost no CPU use (chapter 1).
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.
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:
The raw result of all four runs (laptop-class 12-core machine, noisy shared load):
| Config | Throughput | Theory (threads / 50 ms) | avg | p95 | p99 |
|---|---|---|---|---|---|
| maxThreads=10 | 198 req/s | 200 | 2020 ms | 2036 ms | 2041 ms |
| maxThreads=50 | 983 req/s | 1000 | 405 ms | 417 ms | 430 ms |
| maxThreads=200 | 3,874 req/s | 4000 | 103 ms | 118 ms | 148 ms |
| virtual threads | 5,950 req/s | 8000 (400/0.05) | 67 ms | 104 ms | 160 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).
Closed-loop, 400 clients; embedded Tomcat 10.1.30; Java 21.0.2; shared 12-core machine.
Dots are measured averages; the dashed line is the formula.
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:
- 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.busypinned attomcat.threads.config.maxwhile CPU is low. - Measuring latency only on the server.
http.server.requestsstarts when a worker picks the request up; queue time is invisible. Compare it with the load balancer'sTargetResponseTimeor 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).
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.
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.
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.
Same TomcatLab, same client; the only difference is the executor Tomcat uses (the mechanism spring.threads.virtual.enabled=true configures for Boot's Tomcat):
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".
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.
Mechanism diagram (JEP 444). The 12 carriers are this machine's core count.
| Metric | Platform pool (200) | Virtual threads | Comment |
|---|---|---|---|
| Throughput | 3,874 req/s | 5,950 req/s | +54 %, measured |
| Average latency | 103 ms | 67 ms | measured |
| p99 | 148 ms | 160 ms | not better: CPU contention on a shared box |
| Concurrency limit | threads.max | none | downstream pools now matter |
- 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 throwsSQLTransientConnectionExceptionafter 30 s. Gate scarce resources with aSemaphoreor 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 thesynchronizedcase. Diagnose with-Djdk.tracePinnedThreads=fullor thejdk.VirtualThreadPinnedJFR 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.
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.
Judgement framework, not measured. Reactive code wins on streaming and backpressure, not automatically on request/response latency.
| Question | Spring MVC + virtual threads | Spring MVC + platform threads | WebFlux |
|---|---|---|---|
| Programming model | Plain blocking code | Plain blocking code | Mono/Flux, operators |
| Concurrency limit | Memory, downstream pools | Pool size | Event loops (never block) |
| Blocking JDBC / JPA | Fine | Fine | Must be offloaded or replaced (R2DBC) |
| Debugging, stack traces | Normal | Normal | Async stack traces, harder |
| Streaming + backpressure | Possible, manual | Possible, manual | First-class |
| Best fit | I/O-bound CRUD services on Java 21+ | JDK 17 or CPU-heavy | Streaming, gateways, reactive-only stacks |
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.
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.
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.
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:
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.
Measured with Jackson 2.17.2 and java.util.zip; synthetic but representative order data.
Two separate changes, in order of payoff. First, return a projection:
Second, decide on compression where it pays - typically at the CDN / load balancer edge for clients on slow links, not in every origin pod:
Computed from the measured byte counts and measured gzip CPU time; transfer time = bytes x 8 / link speed, ignoring latency and TCP effects.
| Change | Bytes | Jackson CPU | Verdict |
|---|---|---|---|
| Return entity graph | 131,956 | 0.7-1.25 ms | baseline |
| Slim DTO projection | 8,891 (-93 %) | 0.07-0.09 ms (-90 %) | always worth it |
| + gzip on the graph | 5,258 | +1.5 ms CPU | only for slow clients |
| + gzip on the DTO | 373 | a few tens of microseconds (not separately measured) | cheap but small absolute saving |
- Returning JPA entities. Besides the size, lazy associations trigger N+1 queries during serialisation when
open-in-viewis 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-Encodingheader on the response the client really sees. - Creating a new
ObjectMapperper call. Building one is expensive (module discovery, serializer caches). Inject the Boot-configured singleton. - Pretty-printing and
WRITE_DATES_AS_TIMESTAMPSsurprises inflate bytes; check the actual JSON once withcurl -s ... | wc -c. - Streaming huge lists as one array. Paginate (keyset, see Database) instead of raising limits.
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.
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.
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.
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.
| Mode (median of 3 runs) | p50 | p95 | p99 | Gain from keep-alive at p50 |
|---|---|---|---|---|
| HTTP new connection | 1,040 us | 2,707 us | 4,536 us | - |
| HTTP keep-alive | 444 us | 1,122 us | 3,518 us | 2.3x |
| HTTPS new connection | 5,524 us | 10,742 us | 15,906 us | - |
| HTTPS keep-alive | 762 us | 2,024 us | 4,234 us | 7.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.
Measured on loopback, shared 12-core machine, median of 3 runs of 2,000 sequential requests.
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.
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.
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:
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.
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.
- Client per call.
HttpClient.newHttpClient()/new RestTemplate()inside a method creates a pool that is thrown away; sockets go toTIME_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
HttpClientsupport 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.
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.
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.
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).
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:
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).
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.
Size the pool from the same formula, set every timeout, reuse one client bean, and keep it bounded:
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.
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.
Conceptual; the starting values are illustrative.
| Client | Pooling default | Timeouts to set | Notes |
|---|---|---|---|
JDK java.net.http.HttpClient | Pools per client; no hard cap by default | connectTimeout, per-request timeout | HTTP/2 by default when the server offers it. One shared instance. |
| Apache HttpClient 5 | Small pool (verify defaults); configure max total/route | connect, socket, response, connection-request | Most knobs; evictIdleConnections matters behind LBs. |
Spring RestClient (6.1+) | Whatever the request factory pools | On the factory | Fluent replacement for RestTemplate; synchronous. |
RestTemplate | Default SimpleClientHttpRequestFactory = HttpURLConnection | Set factory timeouts | Maintenance mode; default factory has no timeouts. |
WebClient (Reactor Netty) | Connection provider with max connections and pending acquire queue | responseTimeout, connect timeout, pendingAcquireTimeout | Needs reactive style; do not block on it from the event loop. |
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.
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.
- 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 respondorConnection reseton 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.
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.
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=-.
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.
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:
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.
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.
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).
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.
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).
Topology illustration, not a measurement.
| Topic | What to know | Status |
|---|---|---|
| Cross-AZ latency | Same-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 transfer | Charged per GB in each direction between AZs. Chatty services between AZs can cost real money. | check current pricing |
| ALB HTTP/2 and keep-alive | ALB 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-requests | LB 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 gateway | Outbound calls to the internet via NAT pay data-processing fees and share a port range (about 55k ports per destination). | general guidance |
- 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.
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.
| Technique | What it changes | Cost / caveat | Measured here? |
|---|---|---|---|
spring.main.lazy-initialization=true | Beans created on first use, not at startup | Startup drops, first request slower, and misconfigured beans fail late (in production, not at boot) | no |
| Trim the classpath / auto-configurations | Fewer beans and classes to load | Use the conditions report (--debug) to see what is active | no |
Spring AOT + -Dspring.aot.enabled=true | Moves bean definition processing to build time | Build step; reduced reflection; required for native | no |
| AppCDS / class data sharing | JVM maps pre-parsed classes | Archive must match JDK and classpath | see page 04 |
| Virtual-thread startup cost | None noticeable | - | no |
| Tomcat vs Jetty vs Undertow | Different footprint | Rarely the bottleneck; measure before swapping | no |
-Xlog:startuptime-style logging or the application.started.time actuator metric before and after one change at a time.
- 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.
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.
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 withhttp.server.requests, checktomcat.threads.busyvs 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=truedo 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. SetTCP_NODELAYon 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 finitenetworkaddress.cache.ttland bound connection lifetime.
Cheat Sheet and Recap
| Symptom | Likely cause | Confirm with | Fix |
|---|---|---|---|
| High latency, low CPU, downstream is fast | Request pool exhausted | tomcat.threads.busy = max; thread dump | More threads (bounded) or virtual threads |
| Client-side latency >> server metric | Queueing before the worker | LB TargetResponseTime vs http.server.requests | Same as above; shed load |
| Slow only on first call / after idle | New TCP/TLS connection | Trace "connect" span | Pooled client, keep-alive, eviction |
| Constant 40 ms on reused connections | Nagle + delayed ACK | Packet capture, flag test | TCP_NODELAY |
| Sporadic 502, target 5xx = 0 | App keep-alive < ALB idle | ALB access log target_status_code=- | App timeout > 60 s |
| Outbound call slow in app, fast in downstream | Client pool too small | Pool metrics, thread dump | Size from Little's Law, set timeouts |
| Big responses, CPU in Jackson | Entity graphs / no DTO | Profile + response size | Projection, pagination |
| Traffic goes to a dead IP after failover | DNS / connection caching | Resolve vs connected IP | Finite DNS TTL, max connection lifetime |
- 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.
- 06 Concurrency & Virtual Threads - the thread mechanics behind chapter 4.
- 08 Database Performance - the pool the request threads queue on next.
- 09 Caching - remove the downstream call entirely.
- 10 Distributed Systems - timeouts, retries and breakers.
- 12 AWS Compute & Containers - CPU limits and JVM sizing.
- Spring: REST & MVC, Caching & Resilience, Actuator.