Async Was Slower Than Sync in This Spring Boot Load Test

Most developers assume one thing:

If a request waits on I/O, making it async should make the API faster.

That assumption sounds reasonable. It is also incomplete.

I built a small Spring Boot load test to compare two endpoints with the same simulated workload. One endpoint handled the work synchronously. The other delegated the same work to Spring @Async and returned a CompletableFuture.

The result was not close.

At 200 concurrent users, the sync endpoint stayed around 216 ms P95. The async endpoint reached roughly 5 seconds P95.

This article is not an argument against async programming. It is a reminder that async is a scheduling strategy, not a performance guarantee.


The Experiment

The test application was intentionally small. I did not want database indexes, ORM behavior, network hops, or serialization cost to hide the effect I was trying to observe.

There were only two endpoints:

  • /sync – a normal blocking Spring MVC handler
  • /async – a controller returning CompletableFuture<String> from an @Async service

Both endpoints simulated the same I/O-like delay: 200 ms.

@GetMapping("/sync")
public String sync() throws InterruptedException {
    Thread.sleep(200);
    return "done";
}

The async version moved the same delay into a service method annotated with @Async.

@Async
public CompletableFuture<String> asyncProcess() throws InterruptedException {
    Thread.sleep(200);
    return CompletableFuture.completedFuture("done");
}

@GetMapping("/async")
public CompletableFuture<String> async() throws InterruptedException {
    return asyncService.asyncProcess();
}

The application also used @EnableAsync, but no custom executor was configured. That detail matters.


What I Expected

My initial assumption was simple: the async endpoint should handle more concurrent users because the request thread can hand off the work instead of sitting inside Thread.sleep(200).

That is usually how async is explained. Free the request thread. Do the slow work somewhere else. Improve throughput.

But that mental model quietly skips an important question:

Where does the work go after the request thread hands it off?

It does not disappear. It moves to another executor. If that executor is too small, too generic, or backed by an unhelpful queue, the bottleneck simply changes location.


Load Test Setup

I used JMeter with three concurrency levels. Each scenario ran for 60 seconds.

ScenarioUsersDuration
A5060s
B10060s
C20060s

The metrics I cared about were average response time, P95 latency, and completed request count. Average latency can hide pain. P95 is better at showing what users experience when the system starts queueing.


Results

Sync Endpoint

ScenarioAverageP95Requests
50 users~204 ms~216 ms~13k
100 users~204 ms~215 ms~24k
200 users~205 ms~216 ms~44k

The synchronous endpoint behaved almost exactly as the simulated workload suggested. A request took about 200 ms, and latency stayed stable as concurrency increased.

The most interesting part was not that sync was fast. It was that sync was predictable. At 200 users, P95 was still around 216 ms.

Async Endpoint

ScenarioAverageP95Requests
50 users~1156 ms~1399 ms~2.4k
100 users~2087 ms~2611 ms~2.4k
200 users~3708 ms~5020 ms~2.5k

The async endpoint moved in the opposite direction. More users did not produce more completed work. Throughput stayed roughly capped while latency grew aggressively.

By the 200 user scenario, P95 latency was roughly 23 times higher than the sync endpoint.


The First Wrong Explanation

The tempting explanation is to say, “Async is slower.”

That is too broad, and it teaches the wrong lesson.

@Async was not slow because asynchronous programming is inherently slow. It was slow because this test moved the workload from one thread-management model into another one without tuning the executor that now owned the work.

The sync endpoint used the servlet container request threads directly. Under this particular workload, that was enough. Each request blocked for 200 ms and returned.

The async endpoint introduced another queue and another worker pool. The request could return control earlier, but the actual work still had to wait for an async worker. Under load, that queue became visible as response time.


Root Cause

The root cause was not the 200 ms delay. Both paths paid that cost.

The root cause was executor pressure.

  • The async method used Spring async execution without a purpose-built ThreadPoolTaskExecutor.
  • The work was still blocking because Thread.sleep(200) holds a worker thread.
  • Requests accumulated behind the async executor instead of completing at the rate JMeter was sending them.
  • The controller returned a CompletableFuture, but the client still waited for the future to complete before receiving the response.

That last point is easy to miss. Returning CompletableFuture from a controller does not make the client response instant. It only changes how the server waits before writing the response.

If the async worker pool cannot process tasks fast enough, client-visible latency still rises.


Why Sync Won This Test

The sync endpoint won because the workload was simple and the thread model matched the test.

There was no remote dependency with long unpredictable tail latency. There was no fan-out. There was no opportunity to overlap several independent operations inside one request. There was only one artificial wait.

In that situation, async added coordination cost without adding useful parallelism.

  • The request entered the controller.
  • The controller submitted work to another executor.
  • The async executor had to schedule that work.
  • The response still depended on the submitted work finishing.

That handoff can be worth it when it protects request threads, isolates slow dependencies, or allows multiple operations to run concurrently. In this test, it mostly created another bottleneck.


What This Does Not Prove

This test does not prove that synchronous code is always faster.

It does not prove that @Async is bad.

It proves something narrower and more useful:

Async without executor design can be slower than plain synchronous code.

A better async test would tune a dedicated executor and then measure again. For example:

  • Core pool size and max pool size based on expected concurrency
  • Queue capacity based on acceptable waiting time
  • Rejected execution policy based on failure behavior
  • Separate executors for different dependency classes
  • Metrics for active threads, pool size, queue depth, and task completion time

Without those numbers, async is mostly hope wrapped in an annotation.


The Production Version of This Bug

This small benchmark looks artificial, but the failure mode is common in production systems.

A team sees slow downstream I/O and adds async execution. The API looks cleaner. Request threads appear less busy. For a while, everything seems fine.

Then traffic increases.

Latency starts rising, but CPU is not obviously saturated. Database metrics look normal. The downstream service might even look healthy. The missing dashboard is usually the async executor itself.

If nobody is watching queue depth, active worker count, and rejection behavior, the service can spend most of its time waiting inside an internal queue that does not show up as a database query or HTTP call.

That is why async performance problems often feel mysterious. The bottleneck is no longer where the code visually appears to be doing work.


How I Would Retest It

The next experiment should not simply increase the async pool until the chart looks better. That can hide the problem by throwing threads at it.

I would retest with a dedicated executor and collect executor-level metrics:

  • Active thread count
  • Queue size
  • Completed task count
  • Task wait time before execution
  • Rejection count
  • JVM thread count and context switching pressure

Then I would compare at least three configurations: default async behavior, a bounded dedicated executor, and a deliberately oversized executor.

The bounded executor is usually the most interesting one because it forces an architectural decision: should the service queue, fail fast, shed load, or apply backpressure?


Lessons Learned

  • @Async changes where work runs. It does not remove the cost of the work.
  • A blocking task inside an async worker still consumes a thread.
  • P95 latency exposes queueing much earlier than average latency alone.
  • Executor configuration is part of API design, not an implementation detail.
  • Before adding async, identify what resource you are protecting and what queue you are creating.

Conclusion

The sync endpoint was not faster because blocking is magically better.

It was faster because, for this workload and this configuration, the synchronous path had fewer moving parts and no hidden executor queue.

Async is powerful when the executor is designed for the workload and the system is measured around that design. Without that, it can make a simple API slower while making the root cause harder to see.

Always measure the thread pool you introduce.