Blocking Calls in Spring Boot – How They Kill Scalability
Most Spring Boot applications do not fail due to CPU or memory shortages. They fail because threads are blocked waiting on I/O.
Scalability problems begin with waiting, not computation.
1. How Spring Boot Handles Requests
In a traditional Spring MVC application:
- One HTTP request = one server thread
- The thread is occupied until the response is returned
- Blocking I/O keeps the thread idle but unavailable
HTTP Request
→ Tomcat Thread
→ Controller
→ Service
→ Blocking DB / HTTP / File IO
(Thread waiting)
2. Common Blocking Calls in Real Applications
| Operation | Why It Blocks |
|---|---|
| JPA / JDBC | Waiting on database response |
| RestTemplate | Synchronous HTTP calls |
| Feign (sync) | Thread-per-request model |
| Thread.sleep() | Explicit thread blocking |
| File I/O | OS-level blocking |
3. The Classic Scalability Failure
@GetMapping("/orders")
public List getOrders() {
return orderService.fetchOrders();
}
public List fetchOrders() {
Thread.sleep(200);
return orderRepository.findAll();
}
With 200 ms latency and 200 threads:
Maximum throughput ≈ 1000 requests/sec, even with idle CPU.
Throughput = thread count ÷ blocking time
4. Why @Async Alone Does NOT Fix Blocking
@Async
public CompletableFuture> fetchAsync() {
return CompletableFuture.completedFuture(orderRepository.findAll());
}
This:
- Moves blocking to another thread pool
- Adds context switching
- Does not increase throughput
If the work blocks, async only relocates the blockage.
5. Solution #1 – Reduce Blocking at the Source
❌ Chatty Database Access
for (Long id : ids) {
repository.findById(id);
}
✅ Single Query
@Query("select o from Order o where o.id in :ids")
List findAllByIds(List ids);
Fewer DB calls = fewer blocked threads.
6. Solution #2 – Isolate Blocking Work
Dedicated Blocking Thread Pool
@Bean
Executor blockingExecutor() {
return Executors.newFixedThreadPool(50);
}
@Async("blockingExecutor")
public CompletableFuture generateReport() {
return CompletableFuture.completedFuture(reportService.generate());
}
This prevents request thread starvation.
7. Solution #3 – Virtual Threads (Correct Usage)
Enable Virtual Threads
spring.threads.virtual.enabled=true
Or Explicit Executor
@Bean
Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
Virtual Threads scale blocking I/O, not database capacity.
8. Solution #4 – Fix the Database Bottleneck
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 30000
Enable JDBC Batching
spring.jpa.properties.hibernate.jdbc.batch_size=50
Database throughput defines system throughput.
9. Solution #5 – Asynchronous Controllers
❌ Blocking Controller
@GetMapping("/data")
public List get() {
return service.fetch();
}
✅ Non-Blocking API
@GetMapping("/data")
public CompletableFuture> get() {
return service.fetchAsync();
}
This releases request threads earlier.
10. When Reactive Actually Makes Sense
Reactive is justified only when:
- End-to-end non-blocking stack
- Streaming or backpressure matters
- R2DBC or reactive clients are used
WebFlux + JPA is worse than MVC.
11. Decision Matrix
| Scenario | Best Choice |
|---|---|
| Blocking DB + MVC | Virtual Threads |
| CPU-heavy work | Bounded Executor |
| Streaming APIs | Reactive |
| Legacy system | Thread isolation |
Final Takeaway
Scalability is not about more threads — it’s about less waiting.
Blocking calls fail silently. Fixing them requires understanding where your threads wait, not just where code executes.