Boost SpringBoot API Performance with CompletableFuture Async Techniques

This article explains how to dramatically reduce SpringBoot endpoint latency by refactoring synchronous REST calls into parallel asynchronous tasks using CompletableFuture, handling thread safety, result aggregation, exception isolation, and custom thread pools for optimal throughput.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Boost SpringBoot API Performance with CompletableFuture Async Techniques

1. Introduction

Slow API responses degrade user experience and business efficiency; using multithreading to run independent tasks in parallel can fully utilize system resources and improve overall performance.

2. Interface Optimization

Original synchronous implementation:

@GetMapping("/{id}")
public Map<String, Object> allInfo(@PathVariable("id") Long id) {
  Map<String, Object> score = this.restTemplate.getForObject("http://localhost:9001/api/scores/{id}", Map.class, id);
  Map<String, Object> order = this.restTemplate.getForObject("http://localhost:9001/api/orders/{id}", Map.class, id);
  Map<String, Object> trade = this.restTemplate.getForObject("http://localhost:9001/api/trades/{id}", Map.class, id);
  return Map.of("score", score, "order", order, "trade", trade);
}

Each downstream call takes 1–2 seconds, resulting in a total latency of about 5 seconds.

First async refactor using CompletableFuture.runAsync:

@GetMapping("/{id}")
public Map<String, Object> allInfo(@PathVariable("id") Long id) {
  Map<String, Object> result = new HashMap<>();
  CompletableFuture<Void> scoreTask = CompletableFuture.runAsync(() -> {
    Map<String, Object> score = this.restTemplate.getForObject("http://localhost:9001/api/scores/{id}", Map.class, id);
    result.put("score", score);
  });
  CompletableFuture<Void> orderTask = CompletableFuture.runAsync(() -> {
    Map<String, Object> order = this.restTemplate.getForObject("http://localhost:9001/api/orders/{id}", Map.class, id);
    result.put("order", order);
  });
  CompletableFuture<Void> tradeTask = CompletableFuture.runAsync(() -> {
    Map<String, Object> trade = this.restTemplate.getForObject("http://localhost:9001/api/trades/{id}", Map.class, id);
    result.put("trade", trade);
  });
  scoreTask.join();
  orderTask.join();
  tradeTask.join();
  return result;
}

This reduces total time to about 2 seconds, as shown in the following result images:

2.1 Further Async Improvements

Thread‑safety: shared result map is modified by multiple threads.

Return type: CompletableFuture<Void> does not convey the fetched data.

Result merging: modifying the map inside child threads mixes concerns.

Improved version using CompletableFuture.supplyAsync and proper result aggregation:

@GetMapping("/{id}")
public Map<String, Object> allInfo(@PathVariable("id") Long id) throws Exception {
  CompletableFuture<Map<String, Object>> scoreFuture = CompletableFuture.supplyAsync(() ->
      this.restTemplate.getForObject("http://localhost:9001/api/scores/{id}", Map.class, id));
  CompletableFuture<Map<String, Object>> orderFuture = CompletableFuture.supplyAsync(() ->
      this.restTemplate.getForObject("http://localhost:9001/api/orders/{id}", Map.class, id));
  CompletableFuture<Map<String, Object>> tradeFuture = CompletableFuture.supplyAsync(() ->
      this.restTemplate.getForObject("http://localhost:9001/api/trades/{id}", Map.class, id));
  CompletableFuture.allOf(scoreFuture, orderFuture, tradeFuture).join();
  Map<String, Object> result = new HashMap<>();
  result.put("score", scoreFuture.get());
  result.put("order", orderFuture.get());
  result.put("trade", tradeFuture.get());
  return result;
}

2.2 Exception Isolation

If any downstream call throws, the whole endpoint fails. By adding .exceptionally we can return a placeholder instead of propagating the error:

@GetMapping("/{id}")
public Map<String, Object> allInfo(@PathVariable("id") Long id) throws Exception {
  CompletableFuture<Map> scoreFuture = CompletableFuture.supplyAsync(() ->
      this.restTemplate.getForObject("http://localhost:9001/api/scores/{id}", Map.class, id))
      .exceptionally(ex -> Map.of("data", String.format("接口发生异常: %s", ex.getMessage())));
  // similar for orderFuture and tradeFuture (tradeFuture deliberately throws 1/0)
  CompletableFuture<Map> tradeFuture = CompletableFuture.supplyAsync(() -> {
      System.out.println(1 / 0);
      return this.restTemplate.getForObject("http://localhost:9001/api/trades/{id}", Map.class, id);
  }).exceptionally(ex -> Map.of("data", String.format("接口发生异常: %s", ex.getMessage())));
  CompletableFuture.allOf(scoreFuture, orderFuture, tradeFuture).join();
  return Map.of("score", scoreFuture.get(), "order", orderFuture.get(), "trade", tradeFuture.get());
}

Now a failure in one service does not affect the others.

2.3 Non‑Blocking Controller

Using Callable lets Tomcat release the request thread while the async tasks run, further improving throughput:

@GetMapping("/{id}")
public Callable<Map> allInfo(@PathVariable("id") Long id) throws Exception {
  CompletableFuture<Map> scoreFuture = CompletableFuture.supplyAsync(() ->
      this.restTemplate.getForObject("http://localhost:9001/api/scores/{id}", Map.class, id))
      .exceptionally(ex -> Map.of("data", String.format("接口发生异常: %s", ex.getMessage())));
  // orderFuture and tradeFuture defined similarly
  Callable<Map> cb = () -> {
    CompletableFuture.allOf(scoreFuture, orderFuture, tradeFuture).join();
    return Map.of("score", scoreFuture.get(), "order", orderFuture.get(), "trade", tradeFuture.get());
  };
  return cb;
}

Console logs show the Tomcat thread returns after only a few milliseconds, greatly increasing overall request capacity.

3. Important Considerations

By default CompletableFuture.supplyAsync uses ForkJoinPool.commonPool(). In production you should provide a dedicated ThreadPoolExecutor to control concurrency and avoid resource exhaustion:

private static final ThreadPoolExecutor pool = new ThreadPoolExecutor(18, 18, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000));

@GetMapping("/{id}")
public Callable<Map> allInfo(@PathVariable("id") Long id) throws Exception {
  CompletableFuture<Map> scoreFuture = CompletableFuture.supplyAsync(() ->
      this.restTemplate.getForObject("http://xxx", Map.class, id), pool)
      .exceptionally(ex -> Map.of("data", String.format("接口发生异常: %s", ex.getMessage())));
  // ... other futures using the same pool
  return cb;
}

Specifying a custom pool gives better control over thread count, queue depth, and overall system stability.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

CompletableFutureSpringBoot
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.