Performance Testing for Java Backends: Load, Stress, Soak, Spike
Fast recall
Quick revision
Core answer
Understanding of different perf test types; ability to design tests with SLOs; debugging perf bottlenecks in JVM/Spring
Confusing perf test types; using averages; testing in shared envs; one-off runs without trend
Discuss SLO-driven testing, JFR / async-profiler, JVM warmup, p99 latency, continuous perf testing, capacity planning
Interview-ready guide
Interview answer
Performance testing is not one thing — it's a family of tests answering different questions.
Test types
- Load: does it handle expected traffic?
- Stress: where does it break?
- Soak: does it degrade over time — leaks, GC pressure?
- Spike: how does it react to sudden bursts?
- Capacity: what's our headroom?
For Java backends
- Define SLOs (p50/p95/p99 latency, error rate, throughput) before testing
- Model realistic load with k6, Gatling, JMeter, Locust
- Run against prod-like environment with prod-like data
- Profile during the run (JFR, async-profiler, Prometheus, JVM metrics)
- Goal: identify bottleneck (CPU, GC, DB, network, lock) and fix iteratively
Pitfalls
- Testing in dev/shared envs (unreliable)
- Measuring averages instead of percentiles (hides tail latency)
- No thread/connection pool tuning during run
- Comparing across JVM warmup states
- One-off runs as definitive (use trend analysis)
Best practice
- Continuous perf testing in CI
- Performance budgets per service
- Load tests as release gates for high-traffic services
The family in detail
- Smoke: nothing broken, 5-10 min, light steady
- Load: meets SLO at expected, 30-60 min, steady target RPS, p99 < SLO + error rate < SLO + stable throughput
- Stress: find break point, until failure, ramp indefinitely, identify knee + no crashes (graceful degrade)
- Soak: long-term stability, hours-days, steady moderate, no memory growth + no connection leak + stable latency
- Spike: burst tolerance, 10-30 min, sudden 5-10x, recovery time + no cascading + autoscale works
- Capacity: headroom, 30-60 min/level, stepped ramp, utilization at expected < target (70%)
Common goal
- Characterize behavior under load
- Not just average response time
- Percentiles (p50, p95, p99, p99.9), error rates, throughput, resource utilization (CPU, mem, GC, DB conn, thread pools)
Why it matters
- Production traffic isn't average
- Tail latency (p99, p99.9) defines user experience
- One slow request matters more than 99 fast for the user waiting
Designing load tests for Spring Boot
1. Define SLOs first
- Don't run without knowing 'pass'
- Example: p99 < 200ms, error < 0.1%, sustained 500 RPS
- Derive from product requirements, not 'whatever current is'
2. Build realistic traffic profile
- Mix of operations (80% reads, 15% writes, 5% updates) mirror production
- Think time (realistic pause between requests; without = over-saturate)
- Distribution (parameterized data, CSV of IDs; not all hit same record)
- Auth (real flow, not bypass; tokens cost CPU)
- Payload sizes realistic, not minimal
3. Tools
- k6: modern, JS-scriptable, great cloud option, CI-friendly; my default
- Gatling: excellent for JVM teams; Java/Kotlin/Scala DSL; great reports
- JMeter: GUI + XML; mature; many plugins; verbose
- Locust: Python-based
- Vegeta: simple Go HTTP load gen for quick smoke
- wrk/wrk2: low-level HTTP benchmark; simple endpoints
k6 example essentials
- options.stages: ramp up + sustain + ramp down
- thresholds: p(99)<200, http_req_failed rate<0.001
- check + sleep with random think time
- tags for endpoint-level breakdown
Gatling: Simulation class with rampUsersPerSec/constantUsersPerSec, assertions on responseTime().percentile(99).lt(200)
4. Prod-like environment
- Same instance sizes, network topology, DB sizing
- Realistic data volume (millions, not 100)
- Same Java version, GC config, container limits
- Don't share (noisy neighbor)
- Ideally ephemeral env per run
5. Warm up JVM first
- JIT compilation matters
- Run low-load warmup phase (1-2 min) before measurement
- Discard those numbers
- stages: 2m@50 (warmup) + 5m@500 (measure)
6. Profile during run
- JVM: heap, GC time, GC frequency, threads (Micrometer + Prometheus)
- App: request rate, latency by endpoint, error rate, queue depths
- DB: connection pool usage, slow queries, lock waits, replication lag
- Infra: CPU, mem, network, disk IO
- Profiler: JFR or async-profiler for CPU/alloc/lock hotspots
- jcmd
JFR.start name=loadtest duration=15m filename=loadtest.jfr - View in JDK Mission Control or JFR Analyzer
Reading results
Read percentiles, not average
- Avg 50ms looks great but masks tail
- p50 30ms, p95 100ms, p99 800ms, p99.9 3000ms
- Average lies; tail tells story
- p99/p99.9 is what users feel during incidents
Common bottlenecks | Bottleneck | Symptom | Tool | Fix | | CPU bound | High CPU, low DB/net | JFR CPU profile | Optimize hot path, cache, algo | | GC pressure | High GC time %, frequent pauses | GC logs, JFR alloc | Reduce alloc; bigger heap; G1→ZGC | | DB slow query | High DB CPU/slow log | pg_stat_statements | Index, query plan, denormalize | | Connection pool | Latency spikes when RPS rises | HikariCP metrics | Increase pool; reduce hold time | | Thread pool | Tomcat full; requests queue | Micrometer thread metrics | Increase max; async; virtual threads | | Lock contention | High CPU low throughput | JFR lock contention | Reduce critical section; striping; lock-free | | Network/external | High latency on specific endpoints | Tracing | Cache; circuit breaker; bulkhead; parallelize | | Memory leak | Heap grows over soak; OOM eventually | Heap dump + Eclipse MAT | Find root reference; fix lifecycle | | Cache miss | High DB load with cache | Cache hit rate metric | Pre-warm; increase TTL; size up |
Iterative tuning loop
- 1Run perf test → baseline
- 2Identify #1 bottleneck (don't fix all at once)
- 3Hypothesize fix
- 4Apply in code/config
- 5Re-run → compare
- 6If improved, move to next; else revisit hypothesis
- Keep performance journal: each run, what changed, what improved/regressed
Specifics by test type
Soak
- 4-24 hours moderate load
- Watch heap trend, file descriptors, DB connections, log volume
- Leak = monotonic growth GC can't reclaim
- Heap dump at start + end, compare in MAT/VisualVM
Spike
- Sudden ramp (0→5000 RPS in 30s)
- Watch: autoscaler trigger? How fast? Queue absorbs? Downstream cascade?
- Recovery time as important as peak handling
- Should normalize within seconds after spike
Stress
- Ramp until failure
- Knee of curve: latency rises sharply, throughput plateaus, errors begin
- Maximum capacity number; plan headroom (run at 50-70% of capacity)
- Should fail gracefully (503s, breakers tripping; not crashes/OOM/cascading)
Continuous perf testing
- Smoke perf in CI every merge (5-10 min)
- Full load on staging deploys
- Performance budgets per service: 'this endpoint must handle X RPS at p99 < Y'
- Trend analysis: regression detection; alert on >10% degradation per deploy
- Tools: k6 Cloud, Grafana Cloud k6, Datadog Synthetic, BlazeMeter
Anti-patterns
- 1Testing in dev (unreliable)
- 2Reporting averages (hides tail)
- 3Single client machine (client becomes bottleneck)
- 4No warmup (JIT cold start corrupts data)
- 5Unrealistic data (100-row hides query plan issues)
- 6No monitoring during test
- 7One-off runs (should be repeatable, comparable)
- 8Only happy path (stress error paths too)
- 9No SLOs ('is this fast enough?' with no answer)
- 10Optimization without measurement (guess; prove it)
Strategic considerations
Coordinated vs uncoordinated omission
- Most tools (JMeter default, naive k6) suffer coordinated omission
- When request takes long, subsequent not sent until done; hides latency
- Use open model: Gatling open, k6 constant-arrival-rate, wrk2
- Otherwise p99 dramatically optimistic
Java-specific
- JIT warmup: 1-2 min low load before measurement
- GC tuning: pause is major outlier contributor
- Low-latency: G1 (default), ZGC, Shenandoah (sub-ms pauses on large heaps)
- Tune Xms = Xmx (avoid resize)
- GC time > 5% CPU = problematic
- Virtual threads (JDK 21+, Spring Boot 3.2+): game-changer for IO-bound; test both platform and virtual
- HikariCP default 10 often too low for high RPS; tune via heuristic; watch pendingThreads metric
Capacity planning
- Test at expected load × 1.5-2x (growth + safety)
- Capacity ≠ max throughput; capacity = max at acceptable latency
- Use to size infra and set autoscaler thresholds
Microservices
- Test individual services in isolation
- Then test critical flows end-to-end
- Identify which services bottleneck first
- Slow downstream cascades; bulkheads/breakers prevent
Database load
- DB is most common bottleneck in high-RPS
- Realistic data volume + distribution; not 1000 rows
- Watch query plans (Postgres EXPLAIN ANALYZE)
- Connection pool sizing critical at DB (max_connections) and app (HikariCP)
- Read replicas, caching, denormalization, async writes
Cache effects
- First N requests fill cache; subsequent hit; latency drops
- Decide: cold or warm cache test?
- Cache eviction under load can cause sudden spikes
Production validation
- Synthetic monitoring continuously runs key flows in prod
- RUM captures actual user latency
- Some perf bugs only show in production traffic patterns
- Canary deploys with extra monitoring catch perf regressions before full rollout
Not one-time
- Continuous: every merge runs smoke
- Periodic: full load on releases
- Pre-event: extra capacity tests before known spikes (Black Friday, campaigns)
- Reactive: when perf incident, reproduce + fix
Strategic take
- Pick right test type for question
- Define SLOs first; measure against
- Prod-like env with prod-like data
- Profile + observe during, not just at end
- Iterate on bottlenecks, one at a time
- Embrace continuous perf testing like unit tests
- Tail latency is what users feel; p99/p99.9 is the real number
Bottom line
- Performance testing is engineering discipline, not occasional ritual
- The right tool, the right data, the right environment, the right metrics
- Measure, hypothesize, fix, re-measure
Performance Testing for Java Backends: Load, Stress, Soak, Spike
Direct Answer
Performance testing is not one thing — it's a family of tests that answer different questions: load ('does it handle expected traffic?'), stress ('where does it break?'), soak ('does it degrade over time — leaks, GC pressure?'), spike ('how does it react to sudden bursts?'), and capacity ('what's our headroom?'). For Java backends: define SLOs (p50/p95/p99 latency, error rate, throughput) before testing, model realistic load with a tool (k6, Gatling, JMeter, Locust), run against a prod-like environment with prod-like data, and profile during the run (JFR, async-profiler, Prometheus, JVM metrics). The goal isn't pretty graphs — it's identifying the bottleneck (CPU, GC, DB, network, lock contention) and fixing it iteratively. Common pitfalls: testing in dev / shared envs (unreliable), measuring averages instead of percentiles (hides tail latency), no thread-pool / connection-pool tuning during the run, comparing across JVM warmup states, and treating one-off runs as definitive (use trend analysis instead). Best practice: continuous performance testing in CI, performance budgets per service, and load tests as part of release gates for high-traffic services.
Common Mistakes & Pitfalls
Confusing perf test types; using averages; testing in shared envs; one-off runs without trend
Check your understanding
Answer these without looking back at the article.
- What is coordinated omission and how do tools like Gatling avoid it?
- Walk through diagnosing a soak test that shows growing heap over 12 hours.
- How would you set performance budgets for a Spring Boot service in CI?
- What changes when you switch from platform threads to virtual threads under load?
Related interview prep categories
1Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.