Unlocking Higher Throughput: Mastering Async Requests in Spring Boot

This article explains how Spring Boot can handle HTTP requests asynchronously using Callable, WebAsyncTask, and DeferredResult, detailing the underlying servlet workflow, thread‑pool configuration, and when asynchronous processing truly improves throughput versus CPU‑bound workloads.

Architect
Architect
Architect
Unlocking Higher Throughput: Mastering Async Requests in Spring Boot

Background: Synchronous vs Asynchronous Servlet Processing

Before Servlet 3.0 each HTTP request was processed from start to finish by a single container thread, limiting throughput. Servlet 3.0 introduced asynchronous processing, allowing the container thread to be released early while the business logic continues in another thread, thereby increasing overall request capacity.

Four Async Options in Spring Boot (excluding AsyncContext)

AsyncContext – native servlet API, rarely used because it is cumbersome. Callable – simple wrapper that marks a controller method as asynchronous. WebAsyncTask – a Callable with built‑in timeout, error and completion callbacks. DeferredResult – decouples request handling from result production; the result can be set later from any thread.

Implementation with Callable

A controller returns java.util.concurrent.Callable<String>. The framework calls request.startAsync(), submits the callable to an AsyncTaskExecutor, and the original servlet thread exits while the response remains open.

@GetMapping("/testCallAble")
public Callable<String> testCallAble() {
    return () -> {
        Thread.sleep(40000);
        return "hello";
    };
}

Processing steps:

Controller returns a Callable.

Spring MVC invokes request.startAsync() and hands the callable to the configured AsyncTaskExecutor.

Both DispatcherServlet and any filters release the container thread; the response stays open.

The callable runs in a separate thread, produces a result, and Spring MVC dispatches the request back to the servlet container.

The container thread resumes, processes the returned value, and sends the final response.

By default Spring uses SimpleAsyncTaskExecutor, which creates a new thread for each task and does not reuse threads. In production you should provide a reusable AsyncTaskExecutor (e.g., a thread pool).

Implementation with WebAsyncTask

WebAsyncTask

wraps a Callable and adds callbacks for timeout, error, and completion. The timeout set on the task overrides any global async timeout.

@GetMapping("/webAsyncTask")
public WebAsyncTask<String> webAsyncTask() {
    WebAsyncTask<String> result = new WebAsyncTask<>(30003, () -> "success");
    result.onTimeout(() -> {
        log.info("timeout callback");
        return "timeout callback";
    });
    result.onCompletion(() -> log.info("finish callback"));
    return result;
}

Implementation with DeferredResult

DeferredResult

allows the controller to return immediately while another thread later sets the actual result.

// Global map to hold pending DeferredResult objects
private Map<String, DeferredResult<String>> deferredResultMap = new ConcurrentHashMap<>();

@GetMapping("/testDeferredResult")
public DeferredResult<String> testDeferredResult() {
    DeferredResult<String> deferredResult = new DeferredResult<>();
    deferredResultMap.put("test", deferredResult);
    return deferredResult;
}

@GetMapping("/testSetDeferredResult")
public String testSetDeferredResult() throws InterruptedException {
    DeferredResult<String> dr = deferredResultMap.get("test");
    boolean flag = dr.setResult("testSetDeferredResult");
    if (!flag) {
        log.info("Result already processed, operation ignored");
    }
    return "ok";
}

Processing flow:

Controller returns a DeferredResult and stores it in a shared collection.

Spring MVC calls request.startAsync() and releases the servlet thread; the response stays open.

Later, another thread retrieves the stored DeferredResult and calls setResult.

Spring MVC dispatches the request back to the servlet container, where the result is written to the client.

Because pending DeferredResult objects can accumulate, it is recommended to periodically clean up expired entries using DeferredResult.isSetOrExpired() to avoid memory leaks.

Custom Thread‑Pool Configuration for Async Tasks

@Bean("mvcAsyncTaskExecutor")
public AsyncTaskExecutor asyncTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(10);
    executor.setThreadNamePrefix("fyk-mvcAsyncTask-Thread-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(30);
    executor.initialize();
    return executor;
}

Integrate the executor into Spring MVC's async support:

@Configuration
public class FykWebMvcConfigurer implements WebMvcConfigurer {
    @Autowired
    @Qualifier("mvcAsyncTaskExecutor")
    private AsyncTaskExecutor asyncTaskExecutor;

    @Override
    public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
        // 0 or negative means never timeout
        configurer.setDefaultTimeout(60001);
        configurer.setTaskExecutor(asyncTaskExecutor);
    }
}

When to Use Asynchronous Requests

Async processing improves throughput only when the request spends a significant amount of time waiting (e.g., calling external services, database I/O) while the CPU is idle. For CPU‑bound work that continuously consumes CPU, async merely moves the work to another thread without gaining capacity. In such cases, simply increasing the maximum worker thread count may be sufficient.

Async adds extra thread‑switching overhead, which slightly increases individual request latency, but the overhead is usually negligible compared with the gain in overall concurrency.

Key Takeaways

Use Callable for simple async endpoints.

Prefer WebAsyncTask when you need timeout, error, or completion callbacks.

Choose DeferredResult for scenarios where the result is produced by a different thread or at an indeterminate later time.

Provide a reusable thread pool instead of the default SimpleAsyncTaskExecutor to avoid thread explosion.

Apply async only to I/O‑bound or waiting‑heavy requests to achieve real throughput gains.

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.

ThreadPoolSpring BootServletCallableAsyncDeferredResultWebAsyncTask
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

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.