Blocking Calls in Spring Boot – How They Kill Scalability

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

OperationWhy It Blocks
JPA / JDBCWaiting on database response
RestTemplateSynchronous HTTP calls
Feign (sync)Thread-per-request model
Thread.sleep()Explicit thread blocking
File I/OOS-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

ScenarioBest Choice
Blocking DB + MVCVirtual Threads
CPU-heavy workBounded Executor
Streaming APIsReactive
Legacy systemThread 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.