Performance · 15

Scenario Q&A: Symptom, Then Investigation

More than 55 performance questions in the form interviewers actually ask: a symptom with a number, and you walk the investigation. Every answer follows the same loop used across this series - measure, find the bottleneck, change one thing, prove it - and links back to the lab page where it was measured. Each card carries the model answer and the follow-up trap that separates memorisation from experience.

59
Scenario Qs
8
Topic groups
1
Answer loop
1
Plain-Java run
01
Chapter One - Why It Matters

The Interviewer Wants a Procedure

A senior performance interview rarely asks "what does -Xmx do". It says: p99 doubled after the release, CPU is at 30%, what do you do? The interviewer is not looking for the cause - they cannot know it - but for a procedure that converges on the cause without guessing. Candidates fail by jumping to a favourite fix (a bigger pool, more instances, a GC flag) before they have a number.

Interview answer skeleton = the lab loop
One investigation loop, every question 1 Symptom number + window 2 Layer USE / RED 3 Hypothesis one, falsifiable 4 Evidence tool + command 5 Change ONE one variable 6 Prove same load, p99 not fixed? the hypothesis was wrong - go back to 3, never "also try" a second change

Use the same six moves on every question. State the symptom with a number and a window. Pick the layer with USE (utilisation, saturation, errors) for resources and RED (rate, errors, duration) for services. Form one hypothesis that a command can falsify. Name the exact evidence (tool and command). Change one variable. Prove it with the same load and the percentile that hurt. The lab pages in this series practise exactly that loop on one service, OrderService (a Spring Boot 3 / Java 21 REST service on AWS).

Weak answer vs strong answer
PromptWeak (a guess)Strong (a procedure)
p99 is 3 sIncrease the heap and the thread pool.Is it every request or the tail? Compare p50 vs p99, then a trace of a slow request to attribute the time; only then pick a layer.
Pods keep restartingGive them more memory.Exit code 137 / OOMKilled or a failed probe? If OOMKilled, was it heap or non-heap (NMT)? Only then size the container.
Throughput is flatAdd instances.Which resource is saturated: app CPU, DB, pool, a lock? Adding instances to a shared-DB bottleneck changes nothing.
Analogy: A doctor does not prescribe before taking the temperature. The interview answer is the examination order: vitals (RED/USE), then the right test (profiler, trace, plan), then one treatment, then a follow-up check.
02
Chapter Two - Mental Model

Three Models That Answer Most Questions

Three small models answer a surprising fraction of questions. First, where is the time - attribution beats optimisation. Second, Little's Law (L = λW) ties latency, throughput and concurrency; see Measure First. Third, queues explode near saturation, so utilisation above about 70-80% buys latency spikes, not throughput.

Attribution before optimisation
Where is the time? (800 ms request, example split from the methodology page) database 650 ms other 100 ms legend: green app 30 ms, red database 650 ms, gold serialization 20 ms, blue other 100 ms Rule: attribute first (trace waterfall / Performance Insights), optimise the red segment only. Halving the 30 ms of app code saves 15 ms (2%). Halving the database wait saves 325 ms (41%). Amdahl, in one picture.
SignalWhat it means
Latency up, CPU lowSomething is waiting: a pool, a lock, a downstream, throttling. Reach for a thread dump or trace, not a CPU profile (Profiling Toolbox).
Latency up, CPU highSomething is computing or collecting: hot loop, serialization, GC. CPU profile and GC log.
Only p99 bad, p50 fineA tail source: GC pause, lock convoy, cold cache, retries, fan-out amplification, throttling.
Throughput flat when load risesA saturated shared resource (DB, pool) or a serial section (Amdahl / USL, Measure First).
The hockey stick behind "keep CPU under 70%"
M/M/1 mean response time = S / (1 - utilisation), S = 50 ms (computed by Capacity.java) 50% = 100 ms 80% = 250 ms 90% = 500 ms 95% = 1000 ms utilisation
⚠ Common bugs & edge cases:
  • Quoting average latency. Averages hide the tail; always give p95/p99 and the window.
  • Optimising code that is 4% of the request. Do the attribution first.
  • Treating "CPU is low" as "the system is fine". Low CPU plus high latency is the signature of waiting.
03
Chapter Three - JVM

JVM, Memory & GC Scenarios

Ten questions on heap, GC, JIT, startup and profiling. For each, practise saying the first command out loud.

Periodic spikes: find the clock that matches
p99 spike every 30 s: overlay the candidates on one time axis 0 s 30 s 60 s 90 s 120 s p99 latency GC Pause (grep gc log) does a Pause line sit under each spike? scheduled job / cron CPU or lock burst at :00 and :30? cache TTL = 30 s all keys expire together = stampede CFS throttling cpu.stat nr_throttled rising?
GC log anatomy
Reading one real G1 log line from the capstone run [13.696s][info][gc] GC(17) Pause Young (Normal) (G1 Evacuation Pause) 741M->212M(1024M) 77.973ms uptime GC id type: young, cause heap before after committed pause Questions it answers: how often (allocation rate = 741-212 MB freed per cycle / seconds apart), how long (77.97 ms = a p99 hit for every request in flight), what survived (212M after = live set), and whether the old gen is growing (compare the "after" floor across cycles).
p99 spikes every 30 seconds. Investigate.

Put the spike times on a timeline and overlay candidates that have a 30 s period: GC (-Xlog:gc*), a scheduled job, a cache whose TTL is 30 s, a metrics scrape, JVM safepoints, CFS throttling. Whichever clock matches the spikes wins. If a Pause line sits under each spike, read its cause and size; if not, look at the application.

Follow-up trap: Assuming GC because it is the famous one. A synchronised cache expiry (stampede) or cron burst produces the same shape.

Deep dive: JVM Memory & GC

Pods are OOMKilled but the heap is half full.

Container memory is heap plus metaspace, thread stacks, direct buffers, code cache, GC structures and native libraries. The kernel kills the process when the sum crosses the cgroup limit. Enable Native Memory Tracking (-XX:NativeMemoryTracking=summary, jcmd <pid> VM.native_memory summary), then set -XX:MaxRAMPercentage to leave headroom.

Follow-up trap: Raising -Xmx makes it worse: the heap now claims more of the same limit.

Deep dive: AWS Compute & the JVM

Memory keeps growing. Leak or just load?

Look at the post-full-GC floor over hours: a rising floor is a leak, a stable floor with bigger peaks is load. Take jcmd <pid> GC.class_histogram twice a few minutes apart and diff, then a heap dump and the dominator tree for the retained set. Classic culprits: static maps, listeners never removed, ThreadLocals in pooled threads.

Follow-up trap: Forcing GC to "see" usage. A heap that fills between collections is normal.

Deep dive: JVM Memory & GC

What is the default max heap in a 1 GiB container?

The JVM is container aware (cgroup v2 since JDK 15 in addition to v1). The default MaxRAMPercentage is 25, so about 256 MiB, and below roughly 1792 MiB and 2 CPUs the JVM picks SerialGC (not "server class"). This is measured on the AWS Compute & the JVM page.

Follow-up trap: Setting -Xmx to the container limit. Leave 25-40% for non-heap.

Deep dive: AWS Compute & the JVM

Right after every deploy the first minute is slow. Why?

Cold JVM: interpreted code, C1 then C2 compilation, class loading, empty connection pools and caches. Fixes: readiness gate with a warm-up, AppCDS, lazy init off for hot paths, slow-start in the load balancer, SnapStart or CRaC where available. Measure the warm-up curve instead of asserting it.

Follow-up trap: Claiming a native image is automatically faster: startup and memory improve, peak throughput usually does not.

Deep dive: JIT, Startup & Native

CPU is 100%. First three minutes?

top -H -p <pid> to find the hot thread, convert its id to hex and match nid=0x... in jcmd <pid> Thread.print; or record 30 s of JFR and read the top frames. If the hot threads are GC workers, it is allocation, not business logic.

Follow-up trap: Restarting first. You destroy the evidence.

Deep dive: Profiling Toolbox

Which collector would you pick and why?

Default G1 for balanced services. Parallel when only throughput matters (batch). ZGC (generational in JDK 21 with -XX:+ZGenerational, default generational in JDK 23) for very low pause targets on larger heaps. Decide from a measured pause and throughput comparison on your workload.

Follow-up trap: Quoting pause numbers from a blog; collectors trade CPU for pause, so the answer depends on spare cores.

Deep dive: JVM Memory & GC

OutOfMemoryError: Metaspace. Where do you look?

Class loading without unloading: a classloader leak (redeploys, dynamic proxy or bytecode generation per request). Class histogram and jcmd <pid> VM.metaspace; cap with -XX:MaxMetaspaceSize only after finding the leak.

Follow-up trap: Raising the cap, which only delays the crash.

Deep dive: JVM Memory & GC

A flame graph shows nothing hot, but requests are slow.

A CPU profile only samples running threads. A service that is slow but idle is waiting; use a wall-clock profile or thread dumps and look for BLOCKED/WAITING states and the lock or call they wait on.

Follow-up trap: Trusting a single profile type. Match the profile (CPU, allocation, lock, wall) to the question.

Deep dive: Profiling Toolbox

The allocation rate is huge but nothing leaks. What now?

Find the allocation sites with a JFR allocation profile (ObjectAllocationSample), then remove waste: boxing in hot loops, repeated string building, per-request big buffers, JSON trees. GC cost scales with survivors, but allocation rate sets pause frequency.

Follow-up trap: Tuning GC flags before reducing allocations.

Deep dive: JVM Memory & GC

⚠ Common bugs & edge cases:
  • Giving a flag recipe ("set -Xmx and -XX:+UseG1GC") without evidence. State what log or profile would justify it.
  • Forgetting that JVM version changes the answer: generational ZGC (JDK 21 opt-in), virtual-thread pinning fixed in JDK 24.
04
Chapter Four - Concurrency

Threads, Pools & Virtual Threads

Concurrency answers combine a formula and a symptom. The virtual-thread claims below are version specific; always state the JDK.

When virtual threads help
Virtual-thread myths vs facts (JDK 21) blocking IO fan-out big win: thousands of waiters, few carriers CPU-bound loop no gain: carriers = cores synchronized + blocking pins the carrier on JDK 21 (fixed JDK 24, JEP 491) bounded resource (DB) still need a gate: pool / Semaphore
Request threads are all busy and the queue keeps growing.

Apply Little's Law: concurrency = rate x time in system. If time in system grew (a slow downstream), the same arrival rate needs more threads, so the pool saturates. Take two thread dumps 10 s apart and see what the threads wait on; fix the slow dependency or add a timeout, do not just enlarge the pool.

Follow-up trap: Raising pool size moves the queue downstream and can overload the database.

Deep dive: Concurrency & Virtual Threads

How would you size a thread pool?

CPU-bound: about the number of cores (N+1). IO-bound: N x (1 + W/C), where W is wait and C is compute time per task; example from the Capacity run: 4 cores, 90 ms wait, 10 ms compute gives 40 threads. Then cap by the downstream you protect (DB connections) and validate under load.

Follow-up trap: Using the formula without measuring W and C.

Deep dive: Concurrency & Virtual Threads

Are virtual threads a silver bullet?

No. They make blocking IO cheap (huge concurrent waiters on few carrier threads), so they help fan-out and thread-per-request services. They do nothing for CPU-bound work, and a database pool is still bounded, so protect it with a Semaphore. On JDK 21 synchronized around blocking pins the carrier (fixed in JDK 24, JEP 491). Boot 3.2+: spring.threads.virtual.enabled=true.

Follow-up trap: Claiming they remove the need for timeouts and bulkheads.

Deep dive: Concurrency & Virtual Threads

Latency is high, CPU is low, threads show BLOCKED. Diagnosis?

Lock contention. The thread dump names the monitor (waiting to lock <0x...>) and the owner. JFR's jdk.JavaMonitorEnter events confirm. Fix by shrinking the critical section, lock striping, or concurrent data structures, not by adding threads.

Follow-up trap: Confusing BLOCKED (monitor) with WAITING/TIMED_WAITING (parked, sleeping).

Deep dive: Profiling Toolbox

Someone used parallelStream() in a request handler. Problem?

It runs on the shared common pool whose size derives from the CPU count. Concurrent requests contend for the same few workers, one slow task stalls others, and in a CPU-limited container the pool can be tiny. Use a dedicated executor with a bound.

Follow-up trap: Assuming parallel always means faster; for small collections overhead dominates.

Deep dive: Concurrency & Virtual Threads

What pool does CompletableFuture.supplyAsync use?

Without an executor argument, the common ForkJoinPool (sized from availableProcessors()). Blocking IO inside it starves unrelated work. Pass an explicit executor for blocking tasks.

Follow-up trap: Using the default pool for blocking calls.

Deep dive: Concurrency & Virtual Threads

Why can a bigger pool make the system slower?

Past the saturation knee, extra threads add context switching, cache misses and queueing at the shared resource. Throughput stays flat while latency rises (the Measure First USL curve).

Follow-up trap: Not mentioning a measurement that proves it: run the same load at 2 pool sizes.

Deep dive: Measure First

Explain a lock convoy.

Threads repeatedly queue for one hot lock and get descheduled while holding or waiting, so the lock is idle between hand-offs and throughput collapses while CPU is low. Visible as many BLOCKED threads on one monitor.

Follow-up trap: Saying "deadlock"; a convoy makes progress, just slowly.

Deep dive: Concurrency & Virtual Threads

⚠ Common bugs & edge cases:
  • Using preview APIs as if stable: structured concurrency and scoped values are still preview in JDK 21 (--enable-preview).
  • Presenting thread-count formulas as constants. They are starting points that load tests must confirm.
05
Chapter Five - Spring & HTTP

Spring, HTTP & Network Scenarios

Spring and the network path between the user and your handler. Many of these are 20-minute fixes once you know what to look at.

Keep-alive mismatch
Sporadic 502 from ALB: who closes the idle connection first? client ALB Tomcat idle 60 s (ALB default) keep-alive timeout 20 s: Tomcat closes (FIN) ALB later reuses the dead socket -> 502 Fix: app keep-alive timeout > ALB idle timeout (e.g. 65-75 s vs 60 s). Diagnose: ELB 502 + target response time "-" , no app error log.
Sporadic 502s through an ALB, nothing in the app log.

Classic keep-alive mismatch: the ALB idle timeout (60 s by default) is longer than the app's keep-alive timeout, so the app closes an idle connection and the ALB reuses the dead socket. Make the app timeout longer than the ALB one (server.tomcat.keep-alive-timeout in Spring Boot). Check the load balancer access logs for the 502 with no target response.

Follow-up trap: Confusing 502 (bad or closed connection) with 504 (target did not answer in time).

Deep dive: Spring, HTTP & Network

Tomcat threads are all busy but CPU is 15%.

Threads are blocked on IO: database, downstream HTTP, a lock. Thread dump shows where. Fix with timeouts, caching, async or virtual threads; raising server.tomcat.threads.max only adds waiters.

Follow-up trap: Treating busy threads as CPU work.

Deep dive: Spring, HTTP & Network

After a database failover the app kept calling the old IP.

DNS caching. The JVM caches positive lookups (default 30 s without a security manager; networkaddress.cache.ttl), and connection pools keep sockets to the old node. Lower the TTL, set a max connection lifetime, and validate connections.

Follow-up trap: Blaming the cloud; check the JVM and pool behaviour first.

Deep dive: Spring, HTTP & Network

Is WebFlux faster than MVC?

Not by itself. It uses fewer threads for IO-bound, high-concurrency workloads, but with a blocking database driver you gain little, and CPU-bound code is no faster. Since Java 21, MVC with virtual threads often delivers the concurrency benefit with simpler code. Decide with a load test of your own workload.

Follow-up trap: Rewriting to reactive without measuring.

Deep dive: Spring, HTTP & Network

Each outbound call is slower than it should be. First check?

Connection reuse. A new TCP (and TLS) handshake per call costs round trips; a pooled keep-alive client avoids them. Check that the HTTP client is a shared singleton with a pool and that all three timeouts (connect, read, overall response) are set.

Follow-up trap: Creating a new client instance per request.

Deep dive: Spring, HTTP & Network

JSON serialization shows up at 20% of CPU. Options?

Return smaller DTOs (do not serialize entities with lazy graphs), avoid pretty-printing, reuse the ObjectMapper, compress large payloads, paginate. Prove it with an allocation + CPU profile before and after.

Follow-up trap: Switching libraries before shrinking the payload.

Deep dive: Spring, HTTP & Network

Startup takes 40 s and autoscaling reacts too late.

Measure what the 40 s is: classpath scanning, auto-configuration, bean creation, connection warm-up. Remedies: lazy init for rarely used beans, AppCDS, Spring AOT, a startup probe so traffic arrives only when ready, and scaling on the right signal early enough. See Scaling & Observability.

Follow-up trap: Optimising startup while the real delay is the JIT warm-up after readiness.

Deep dive: JIT, Startup & Native

What is a sensible readiness vs liveness setup?

Liveness answers "restart me?" and should not check dependencies (a database blip would restart every pod). Readiness answers "send me traffic?" and may reflect warm-up and dependency health. Use a startup probe for slow boots.

Follow-up trap: Pointing liveness at a deep health check, causing restart storms.

Deep dive: Scaling & Observability

⚠ Common bugs & edge cases:
  • Tuning Tomcat before looking at the downstream calls the threads wait on.
  • Forgetting that the load balancer has its own timeouts; 502/504 are often about the boundary, not the handler.
06
Chapter Six - Database

Database Scenarios

Database questions are the most common because the database is usually the 650 ms in the 800 ms request.

Symptom shape of connection starvation
HikariCP: threads wait for a connection (pool=4, 16 request threads) 4 hold a connection (green) 12 wait in HikariPool.getConnection (red) up to connectionTimeout conn 1 conn 2 conn 3 conn 4 database hikaricp.connections.pending > 0 hikaricp.connections.acquire (timer) climbs SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
An endpoint takes 800 ms. How do you find the slow part?

Attribute before tuning: a distributed trace or span breakdown (or Performance Insights for the database) splits the request into app, database, downstream and serialization. In the example 650 ms are the database; only then examine its queries, count per request, and plans.

Follow-up trap: Starting with a CPU profile; the time is spent waiting, not computing.

Deep dive: Database Performance

HikariPool: Connection is not available, request timed out after 30000ms.

Requests wait longer than connectionTimeout for a connection. Check hikaricp.connections.pending and acquire time, look for long transactions or leaked connections (leakDetectionThreshold), slow queries holding connections, and whether pool x instances exceeds the database limit.

Follow-up trap: Just raising maximumPoolSize, which can saturate the database.

Deep dive: Database Performance

How do you detect and fix N+1 queries?

Count statements per request (our capstone counter showed 81 per request). Fix with a join fetch, @EntityGraph, batch fetching (hibernate.default_batch_fetch_size), or a DTO projection. Verify the statement count dropped and the latency followed.

Follow-up trap: Using EAGER fetching, which hides N+1 rather than removing it.

Deep dive: Database Performance

A query is slow though there is an index. Why?

Possible causes: a function or cast on the column, a type mismatch, a low-selectivity predicate, wrong composite column order, stale statistics, or the planner chose a scan because it is cheaper. Read the plan (estimated vs actual rows) instead of guessing.

Follow-up trap: Adding indexes blindly: each one slows writes and consumes memory.

Deep dive: Database Performance

Deep pages with OFFSET get slower. Why and how to fix?

The database still reads and discards all skipped rows. Use keyset (seek) pagination: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20 with a matching index.

Follow-up trap: Forgetting a stable tiebreaker column.

Deep dive: Database Performance

Doubling the pool made things worse. Explain.

A database has a finite number of useful concurrent executions (cores, IO). Beyond that, extra connections queue inside the database and add contention. Size the pool from the database side and use the pending metric to tell starvation from saturation.

Follow-up trap: Quoting the formula without measuring.

Deep dive: Database Performance

How do read replicas help and what do they break?

They offload reads and add capacity, but replication lag gives stale reads: read-your-writes needs routing the next read to the primary or tolerating staleness. They do not help write-heavy bottlenecks.

Follow-up trap: Sending every read to the replica including post-write reads.

Deep dive: Database Performance

Latency spikes line up with "idle in transaction" sessions.

Transactions held open across slow work (remote calls inside a @Transactional method) hold connections and locks. Narrow transaction scope; do remote calls outside it; set statement and idle-in-transaction timeouts.

Follow-up trap: Keeping HTTP calls inside the transaction boundary.

Deep dive: Database Performance

⚠ Common bugs & edge cases:
  • Giving numbers that were not measured. The plan outputs in the Database Performance page are labelled illustrative where they are not from this sandbox.
  • Treating the ORM as the problem when a missing index or a long transaction is.
07
Chapter Seven - Cache & Distributed

Caching, Messaging & Resilience Scenarios

Caching and distributed-systems questions are mostly about what multiplies: misses, retries, fan-out, queues.

Why fan-out turns p99 into p50
Tail amplification measured by the Capacity program: P(at least one of N calls is in the slow 1%) N=1 1.0% N=5 4.9% N=10 9.6% N=50 39.5%
Why retries need budgets
Retry storm: a 2 s blip becomes a long outage (illustrative) no jitter / no breaker: 3x retries keep load above capacity timeouts + jitter + circuit breaker: recovers blip
A hot key expires and the database melts. What happened?

Cache stampede: many requests miss simultaneously and all recompute. Fixes: single-flight (one loader per key), jittered TTLs, stale-while-revalidate, or refresh-ahead. Measure hit ratio and backend load around expiry.

Follow-up trap: Making TTLs identical, so every key expires together.

Deep dive: Caching

What hit ratio do you need to make a cache worthwhile?

Effective latency = h x t_cache + (1 - h) x t_db. With a 20 ms backend and a 1 ms cache, 90% hit gives about 2.9 ms; 50% gives 10.5 ms. Compute it for your numbers and watch the miss path, which sets the tail.

Follow-up trap: Ignoring the cost and staleness of invalidation.

Deep dive: Caching

p50 is fine; p99 is 10x worse with five downstream calls.

Tail amplification: with five calls that each have a 1% slow tail, about 4.9% of requests hit at least one (Capacity run). Reduce fan-out, use hedged requests for idempotent reads, set timeouts from a latency budget, and cache the slow ones.

Follow-up trap: Adding retries without a budget, which adds load to the slow dependency.

Deep dive: Distributed Systems & Messaging

Explain a retry storm and how to prevent it.

When a dependency slows, clients time out and retry, multiplying load so it cannot recover. Use exponential backoff with jitter, a retry budget, per-call timeouts, a circuit breaker and load shedding. Retry only idempotent operations.

Follow-up trap: Retrying at every layer: multiplying factors (3 x 3 x 3).

Deep dive: Distributed Systems & Messaging

SQS messages are processed twice. Why?

The visibility timeout expired before processing finished, so the message became visible again. Set the timeout above worst-case processing time, extend it, and make handlers idempotent (SQS standard is at-least-once). Use a DLQ for poison messages.

Follow-up trap: Assuming exactly-once delivery.

Deep dive: Distributed Systems & Messaging

Kafka consumer lag keeps growing. Investigation?

Compare produce rate to consume rate. If consumers are slow, profile the handler (usually a downstream call); then scale consumers up to the partition count, raise max.poll.records carefully, or batch. Watch rebalances caused by slow polls (max.poll.interval.ms).

Follow-up trap: Adding consumers beyond the number of partitions; the extras sit idle.

Deep dive: Distributed Systems & Messaging

How should timeouts be set across a call chain?

From a latency budget: the outer timeout must exceed the sum of inner timeouts and retries, and a deadline propagates downward so nobody works on a request the caller already abandoned.

Follow-up trap: Equal default timeouts at every hop.

Deep dive: Distributed Systems & Messaging

When do you shed load instead of scaling?

When demand exceeds capacity faster than you can add it or a shared dependency is the bottleneck. Reject early (429/503) the work you cannot finish, protecting latency for the work you accept, and prefer failing fast to queueing unbounded.

Follow-up trap: Unbounded queues: they convert overload into huge latency.

Deep dive: Capacity & Failure

⚠ Common bugs & edge cases:
  • Describing exactly-once delivery as a property of the broker; in practice you get at-least-once and design idempotency.
  • Using a cache to hide a slow query instead of fixing the query when the hit ratio will be low.
08
Chapter Eight - AWS

AWS Compute & Scaling Scenarios

AWS questions blend infrastructure knobs with JVM behaviour. The container experiments (memory limit, CFS throttling, available processors) are on AWS Compute & the JVM.

Why low average CPU still means latency spikes
CFS quota = 100 ms CPU per 100 ms period (limit: 1 CPU); 8 threads burn at once 12 throttled ~88 ms 12 throttled ~88 ms 12 throttled ~88 ms 8 threads exhaust 100 ms of quota in ~12.5 ms wall time, then every thread (incl. the request path) is frozen until the next period. Average CPU over a second can be 40% while a 1 ms request sees an 88 ms stall. Metric: container_cpu_cfs_throttled_periods_total.
Cold start anatomy
Java on Lambda: where cold-start time goes (shape, not measured here) class load + static init Spring context init env | runtime | class load/static init | framework wiring (largest for Spring) | first invoke Levers: SnapStart (restore a snapshot), lazy init, AppCDS, smaller classpath, more memory (= more CPU), provisioned concurrency.
Latency spikes in ECS/EKS although average CPU is 30%.

Suspect CFS throttling. A CPU limit enforces a quota per 100 ms period; bursts across many threads (GC, JIT, request bursts) exhaust it and freeze all threads. Check container_cpu_cfs_throttled_periods_total or cgroup cpu.stat (nr_throttled). Our container demo measured it. Options: raise or remove the CPU limit, keep requests, reduce threads.

Follow-up trap: Looking only at average CPU utilisation.

Deep dive: AWS Compute & the JVM

Adding instances did not increase throughput.

Find the shared saturated resource: database CPU/IOPS/connections, a lock, a downstream rate limit, or a sticky load balancer. Check the connection budget: instances x pool must fit max_connections (9 instances x 20 = 180 in the example). Scaling stateless tiers cannot lift a shared bottleneck.

Follow-up trap: Scaling harder, which often makes the shared resource worse.

Deep dive: Capacity & Failure

Autoscaling reacts too late during a traffic ramp.

Scale-out has lag: metric period, cooldown, task start, image pull, JVM warm-up. Scale on a leading signal (request rate per target, queue depth) rather than CPU alone, keep headroom, use step or predictive policies, and make startup faster.

Follow-up trap: Tuning the threshold when the real lag is the 90 s startup.

Deep dive: Scaling & Observability

Lambda (Java) first request takes seconds. Options?

It is the cold start: init plus class loading plus framework wiring. Use SnapStart for supported Java runtimes, provisioned concurrency to keep environments warm, trim dependencies, avoid heavy frameworks, and raise memory (CPU scales with memory). Measure cold vs warm separately.

Follow-up trap: Believing more memory is always more cost; shorter duration can offset it.

Deep dive: AWS Compute & the JVM

Is Graviton a free win?

Often a price/performance win (arm64), but you must have arm64 images (multi-arch build), arm64 native libraries, and you should load-test your workload on both. Compare cost per request, not instance price.

Follow-up trap: Skipping load tests because the JVM is portable.

Deep dive: Cost & Capstone

ALB shows unhealthy targets that flap under load.

The health check endpoint is probably doing too much (deep dependency checks) or is queued behind slow requests, so a loaded but working task fails health checks and is killed. Keep health checks shallow and fast, tune thresholds, and separate liveness from readiness.

Follow-up trap: Health check path that hits the database.

Deep dive: Scaling & Observability

What is the difference between 502, 503 and 504 from an ALB?

502: the target returned a malformed response or closed the connection. 503: no healthy targets (or the service shed load). 504: the target did not respond within the idle timeout. Each points to a different layer.

Follow-up trap: Treating all of them as "the app is down".

Deep dive: Spring, HTTP & Network

Fargate task exits with code 137. Meaning?

SIGKILL, usually the OOM killer because the task memory limit was exceeded (check the stopped reason "OutOfMemoryError: Container killed due to memory usage"). Compare heap settings with the task memory; account for non-heap memory.

Follow-up trap: Reading 137 as an application crash and searching app logs only.

Deep dive: AWS Compute & the JVM

Where does cross-AZ traffic hurt?

It adds latency (a millisecond or so, deployment specific) and a per-GB charge, and chatty services multiply it. Prefer AZ-local routing where practical, and cache or batch calls across AZ boundaries.

Follow-up trap: Ignoring the data transfer line item in the bill.

Deep dive: Cost & Capstone

⚠ Common bugs & edge cases:
  • Quoting AWS limits or prices from memory; they change. Say "check the current documentation" and show how you would compute it.
  • Treating Lambda, Fargate and EC2 as interchangeable; the performance levers (cold start, quota, warm-up) differ.
09
Chapter Nine - Capacity

Capacity, Method & Cost Scenarios

The last group is estimation and judgement. Show the arithmetic, then say what you would validate.

Why more instances did not raise throughput
Adding instances: throughput curve flattens at the shared DB (illustrative shape) ideal linear measured (DB saturated) instances req/s next step: check DB CPU/IOPS/connections, not the app fleet
Instances x pool size must fit the database
Connection budget (illustrative numbers from the worked answer) 20 max_connections = 200 -> 180 usable reserved 20: admin, monitoring, replicas, migration jobs inst 1 x20 inst 2 x20 inst 3 x20 inst 4 x20 inst 5 x20 inst 6 x20 inst 7 x20 inst 8 x20 inst 9 x20 9 instances x pool 20 = 180: the 10th instance (or one rolling deploy surge) hits "too many connections".
Traffic is expected to double. What happens?

Compute utilisation at 2x: if you run at 50% today you reach 100% and latency explodes (S/(1-ρ)). Then check the shared limits: DB connections, DB CPU, downstream quotas, and scale-out lag. Answer with numbers, then a load test to validate.

Follow-up trap: Assuming linear scaling.

Deep dive: Capacity & Failure

How many instances for 5,000 RPS at 50 ms?

Little: 5,000 x 0.05 = 250 requests in flight. If an instance handles 40 concurrently at target utilisation, you need about 7, plus headroom for N+1 failure/deploy surge. Then check the database budget.

Follow-up trap: Forgetting headroom and the failure of one AZ.

Deep dive: Capacity & Failure

The load test passed but production is slow. Why?

Typical gaps: data volume (indexes behave differently), no think time or unrealistic key distribution, a closed workload model that hides queueing (coordinated omission), a cold cache, a smaller dependency. Make the test resemble production.

Follow-up trap: Trusting a test that waits for each response before sending the next.

Deep dive: Benchmarking & Load Testing

Users complain, but the dashboard average is fine.

Averages hide the tail. Show p95/p99 per endpoint and per customer segment, histogram buckets, and error rate. Define SLIs and SLOs and alert on burn rate.

Follow-up trap: Alerting on average latency.

Deep dive: Scaling & Observability

How do you prove an optimisation worked?

Same load, same data, same environment, repeated runs, report the percentile that hurt, and change one variable at a time. In the capstone each step shows a before/after row so the gain is attributable.

Follow-up trap: Changing three things in one deploy and claiming credit.

Deep dive: Cost & Capstone

When should you NOT optimise?

When the code is not on the critical path, the gain is below the noise, or the SLO is already met and the change adds risk. Measure the headroom first.

Follow-up trap: Premature micro-optimisation (and benchmarking without JMH discipline).

Deep dive: Benchmarking & Load Testing

Which failure scenarios do you rehearse?

Database 5x slower, cache down, consumer lag, a downstream timing out, 10x spike, a leaking deploy. For each: expected symptoms, blast radius, protections (timeouts, bulkheads, shedding) and a game day or fault-injection test.

Follow-up trap: Documenting scenarios but never running them.

Deep dive: Capacity & Failure

How do you reduce cost per request?

Cost per request = hourly cost / sustained requests per hour. Raise throughput on the same infra (fix N+1, indexes, caching), right-size, use Graviton and commitments, and cut idle headroom without breaking the SLO. See the capstone.

Follow-up trap: Cutting instance size without checking tail latency.

Deep dive: Cost & Capstone

⚠ Common bugs & edge cases:
  • Giving a precise-looking number without stating assumptions (service time, target utilisation, failure headroom).
10
Chapter Ten - Try It Yourself

Run the Interview Calculator

Most numeric answers in this chapter come from five formulas. This plain-Java program (no dependencies) computes them; compile-free run with java Capacity.java on Java 21.

Java - Capacity.java (the interview calculator)
/** The five back-of-envelope answers every performance interview asks for. */ public class Capacity { public static void main(String[] a) { // 1. Little's Law: L = lambda * W double rps = 800, w = 0.120; System.out.printf("1. Little: %.0f req/s x %.0f ms => %.0f requests in flight%n", rps, w * 1000, rps * w); // 2. Thread pool for an IO-heavy service: N * (1 + W/C) int cores = 4; double wait = 90, compute = 10; System.out.printf("2. Pool: %d cores, wait %.0f ms, compute %.0f ms => %.0f threads%n", cores, wait, compute, cores * (1 + wait / compute)); // 3. Tail amplification when a request fans out to N services that each have a 1%% slow tail for (int n : new int[]{1, 5, 10, 50}) System.out.printf("3. Fan-out %2d: P(at least one slow call) = %.1f%%%n", n, 100 * (1 - Math.pow(0.99, n))); // 4. Connection budget int maxConn = 200, reserved = 20, pool = 20; System.out.printf("4. DB budget: (%d - %d) / pool %d => max %d instances before the DB refuses connections%n", maxConn, reserved, pool, (maxConn - reserved) / pool); // 5. The hockey stick: M/M/1 response time = S / (1 - utilisation) double s = 50; for (double u : new double[]{0.5, 0.7, 0.8, 0.9, 0.95, 0.99}) System.out.printf("5. Utilisation %.0f%%: mean response = %.0f ms (service time %.0f ms)%n", u * 100, s / (1 - u), s); } }
Real output (java 21.0.2)
1. Little: 800 req/s x 120 ms => 96 requests in flight 2. Pool: 4 cores, wait 90 ms, compute 10 ms => 40 threads 3. Fan-out 1: P(at least one slow call) = 1.0% 3. Fan-out 5: P(at least one slow call) = 4.9% 3. Fan-out 10: P(at least one slow call) = 9.6% 3. Fan-out 50: P(at least one slow call) = 39.5% 4. DB budget: (200 - 20) / pool 20 => max 9 instances before the DB refuses connections 5. Utilisation 50%: mean response = 100 ms (service time 50 ms) 5. Utilisation 70%: mean response = 167 ms (service time 50 ms) 5. Utilisation 80%: mean response = 250 ms (service time 50 ms) 5. Utilisation 90%: mean response = 500 ms (service time 50 ms) 5. Utilisation 95%: mean response = 1000 ms (service time 50 ms) 5. Utilisation 99%: mean response = 5000 ms (service time 50 ms)

Change one input at a time and say aloud what changes: raise the wait time to 180 ms and the pool formula says 76 threads, but the database connection budget still caps you at 9 instances of 20. That conversation - formula, then limit - is the interview.

11
Chapter Eleven - Rapid-fire

Concept Check Table

A one-line answer for each classic concept question. Say it, then add one number or one tool.

QuestionOne-line answer
What does p99 mean?99% of requests were at least this fast; 1 in 100 is slower.
Little's Law?L = λ x W: concurrency = arrival rate x time in system.
USE vs RED?USE (utilisation, saturation, errors) for resources; RED (rate, errors, duration) for requests.
Coordinated omission?A closed-loop tester stops sending while the system is stalled, hiding the stall. Use an open model and time from the intended start.
G1 vs ZGC?G1: balanced, region-based. ZGC: concurrent, very low pauses; generational in JDK 21 (opt-in), default in JDK 23.
Why metaspace?Class metadata lives off-heap; grows with loaded classes.
What is TLAB?Thread-local allocation buffer: lock-free bump-pointer allocation inside eden.
Virtual thread carrier?A platform thread on which virtual threads are mounted; blocking IO unmounts them.
What pins a virtual thread (JDK 21)?Blocking inside synchronized or a native frame; fixed for synchronized in JDK 24.
Hikari pending metric?Threads waiting for a connection; non-zero means starvation.
Cache-aside?App reads cache, on a miss loads the DB and fills the cache.
Idempotent?Repeating the call has the same effect: required before retries.
Why jitter?Randomised delay de-synchronises clients so retries do not arrive together.
What is SnapStart?Lambda restores a snapshot of an initialised function to cut cold start (supported Java runtimes).
MaxRAMPercentage default?25% of container memory (heap max).
CFS quota?Container CPU limit enforced as milliseconds of CPU per 100 ms period.
⚠ Common bugs & edge cases:
  • Reciting a definition and stopping. Add how you would observe it (metric, log, command).
12
Chapter Twelve - Summary

Cheat Sheet, Recap & Where Next

Cheat sheet: symptom to first tool
SymptomLikely causeFirst tool
Periodic p99 spikeGC, cron, synchronised TTL, throttlinggc log, cpu.stat, timeline overlay
OOMKilled, heap half fullNon-heap memoryNMT, container metrics
Low CPU, high latencyWaiting: pool, lock, downstreamthread dump, trace
Connection is not availablePool starvation, long txnsHikari metrics, leak detection
Flat throughput with more instancesShared bottleneckDB metrics, USL, connection budget
Sporadic 502Keep-alive mismatchLB logs, keep-alive timeouts
Slow only after deployCold JVM / cacheswarm-up curve, readiness
Recap
  • Procedure beats recall: symptom with a number, layer, one hypothesis, evidence, one change, proof.
  • Attribute the time before optimising: 650 of 800 ms was the database.
  • Low CPU plus high latency means waiting; high CPU means compute or GC.
  • Tails multiply: fan-out, retries, queues and utilisation near 100%.
  • Know the version caveats: JDK 21 vs 24 pinning, generational ZGC, preview APIs.
  • Measure before and after, one variable at a time, and say what you could not measure.
Next steps
Related Spring interview page