Spring Boot At 10x Load: The Patterns That Survive Production
Stop guessing at Spring Boot performance.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Thread pool sizing is a trap; measure blocking, not CPU cores
- Connection pools must be tuned for your specific DB latency, not defaults
- Reactive isn't always faster; it shifts the bottleneck, doesn't remove it
- Caching is a hot path invariant, not an afterthought
- Metrics without action are just expensive log files
Imagine a busy kitchen. If you have one chef doing everything, orders pile up. Spring Boot is like that kitchen. High traffic handling is about having the right number of chefs (threads), the right ovens (databases), and knowing when to prep food in advance (caching) vs cooking on demand. Get it wrong, and customers leave angry. Get it right, and you serve thousands without breaking a sweat.
Thursday, 2:47 AM. PagerDuty screaming. 500 errors flooding in. Your customers can't check out. Your boss is calling. Your hands are sweating. Welcome to the club.
I've been there more times than I care to count. Every time, the root cause is the same: someone assumed default configuration would handle production load. It never does. Spring Boot defaults are for getting started, not for getting paid.
The worst part? The fix is usually small. A config change. A thread pool limit. A missing index. But those small things compound into catastrophic failures when traffic spikes. Black Friday. Product launch. A tweet from an influencer. 10x load in 30 seconds. Your app melts.
Here's the hard truth: most performance problems aren't bugs. They're design flaws exposed by load. Your code works fine at 100 RPS. At 1000 RPS, every sin shows up. Blocking calls on the main thread. Lazy initialization in request paths. Connection pools that assume 100ms queries but get 2-second ones under load.
You can't wing this. You need a strategy. You need to know your numbers. What's your average response time at idle? What's your P99 at 80% CPU? If you don't know those numbers, you're not engineering. You're hoping.
I'm writing this because I've seen too many teams burn out on performance fires that were predictable and preventable. This isn't a "best practices" list. This is a survival guide. Patterns that actually work in production. Trade-offs you need to make. Incidents that taught me painful lessons. Read it. Apply it. Stop getting paged.
Thread Pools: The First Thing That Breaks
Every junior thinks more threads = more speed. Wrong. Threads are not free. Each thread eats stack memory (default 1MB on 64-bit JVM). 200 threads = 200MB just for stacks. And that's before any object allocations. The real sin: threads fighting over locks. When your DB connection pool is 10 and you have 200 threads, 190 threads are doing nothing but spinning. They're not idle — they're burning CPU in park loops.
I once diagnosed a service where the P99 was 30 seconds. The team added more threads. It got worse. The fix: drop threads to 50, increase connection pool to 30. P99 dropped to 200ms. The lesson: measure queue depth, not thread count. Use Micrometer's tomcat.threads.busy metric. If it's close to config.max, you're not thread-starved. You're downstream-starved. The threads are waiting on something else (DB, API, cache). Adding more threads just makes that thing wait harder.
Virtual threads (Project Loom) change this equation. They're lightweight enough to have thousands. But they're not magic. If you block a virtual thread on a synchronized block, it pins the carrier thread. Monitor this with jdk.VirtualThreadPinned events. Virtual threads don't fix bad queries. They just let you wait more efficiently.
Rule of thumb: match your thread pool size to your connection pool size times some factor (1.5x-2x). Never exceed the number of connections. And always use a bounded queue. Unbounded queues in ephemeral thread pools will OOM your heap. I've seen it. It's not pretty.
server.tomcat.threads.max above your HikariCP maximum-pool-size. You'll create thread starvation disguised as DB slowness. Monitor tomcat.threads.busy — if it hits max, you've found your bottleneck.Connection Pooling: The Silent Killer
HikariCP is the default. It's fast. But defaults will burn you. maximum-pool-size=10 is fine for a toy app. For production, you need to know your DB's max connections and your query latency. Formula: pool size = (peak TPS average query duration in seconds) / (number of app instances). For example: 1000 TPS 0.05s avg query = 50 concurrent queries. If you have 5 instances, each needs at least 10 connections. But real life isn't that clean. Add buffer for spikes. I usually target 1.5x the calculated value.
Here's the gotcha: connection pools are per-datasource. If you have read replicas, don't pool them the same way. Read replicas handle more concurrent connections, so you can pool higher. But no connection pool should exceed the DB's max_connections. Otherwise, you'll get the dreaded FATAL: sorry, too many clients already. Fix: set spring.datasource.hikari.maximum-pool-size=30 and spring.datasource.hikari.minimum-idle=5. The idle connections keep startup fast. The max prevents DB overload.
leak-detection-threshold is your friend. Set it to 60 seconds. If a connection is held longer than that, HikariCP logs a stack trace. You'll catch bugs like "forgot to close PreparedStatement" or "transaction never committed." I caught a memory leak in legacy code this way. 30 minutes of investigation saved an outage.
Connection timeout is critical. Don't set it too high. 30 seconds is the default. Under load, threads pile up waiting for connections that never come. That becomes a thread pool problem. Lower connectionTimeout to 5-10 seconds. Fail fast. Let the client retry. Don't let threads queue up waiting for a connection that's not coming.
spring.datasource.hikari.leak-detection-threshold=60000. It logs a stack trace when a connection is held too long. You'll find your slow queries and missing close() calls fast.Reactive vs Imperative: Pick Your Poison
Reactive (WebFlux) isn't faster. It's different. It trades thread-per-request for event-loop-driven processing. This makes sense when you have many I/O-bound operations (DB calls, HTTP calls) and you're hitting thread limits. But reactive has a cost: debugging is harder, stack traces are useless, and you need to be reactive all the way down. One blocking call in a reactive pipeline ruins everything.
I've seen teams adopt reactive because "it's more scalable." Then they spend weeks debugging why their reactive chain hangs. The root cause? A Thread.sleep() in a flatMap. Or a synchronized block. Or a legacy library that uses blocking I/O. Reactive is not a performance upgrade. It's a programming model shift. Do it for the right reasons: high concurrency with limited resources.
For most CRUD apps, imperative with virtual threads is the sweet spot. Virtual threads let you write blocking code without blocking a carrier thread. You get performance parity with reactive for 10% of the complexity. But beware: virtual threads pinned by synchronized or native frames. Profile with -Djdk.tracePinnedThreads=short. If you see pinned threads, refactor those synchronized blocks to ReentrantLock or use the concurrency utilities from java.util.concurrent.
Here's my rule: if your request handler makes more than 3 I/O calls, reactive might win. If it's 1-2 calls, virtual threads are simpler and faster to debug. If it's CPU-bound, neither helps — you need better algorithms. Measure, don't guess.