DEV Community

Ahmad Naqibul Arefin
Ahmad Naqibul Arefin

Posted on

Building Scalable Asynchronous Backends with Spring Boot and Virtual Threads

In modern application architectures, designing a backend that can efficiently handle heavy loads while providing timely responses is crucial. In this blog post, we'll explore how to build a scalable asynchronous backend service using Spring Boot. We'll cover traditional asynchronous processing with DeferredResult and @Async, and then introduce virtual threads (from Project Loom) that allow you to write blocking code in a natural style while still scaling to massive concurrency.


Table of Contents

  1. Introduction
  2. Asynchronous Processing in Spring Boot
  3. Using DeferredResult to Manage Responses
  4. Customizing Thread Pools for Heavy Loads
  5. Virtual Threads: Simplifying Blocking Code
  6. Complete Example
  7. Conclusion

Introduction

When your backend service needs to handle up to a million concurrent requests, every millisecond counts. Traditional synchronous processing can easily lead to thread exhaustion and increased latency. Asynchronous processing decouples request handling from heavy business logic, ensuring each request is processed independently. In this post, we'll dive into how you can achieve this using Spring Boot's asynchronous support—and how virtual threads can further enhance scalability by letting you write blocking code without the overhead of traditional threads.


Asynchronous Processing in Spring Boot

Spring Boot makes it easy to run tasks asynchronously. By using the @Async annotation on service methods, you can offload heavy processing tasks to a separate thread pool. This approach ensures that your web container (like Tomcat or Netty) isn't blocked by long-running operations.

For example, consider a service method that simulates a heavy computation:

@Service
public class AsyncService {

    @Async("taskExecutor")
    public CompletableFuture<String> processTask(String input) {
        try {
            // Simulate a heavy process, e.g., I/O or computation.
            Thread.sleep(3000); // 3-second delay
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.completedFuture("Processing interrupted");
        }
        return CompletableFuture.completedFuture("Processed input: " + input);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the @Async annotation dispatches the method to a separate thread, freeing up the web request thread almost immediately.


Using DeferredResult to Manage Responses

While asynchronous processing decouples the heavy logic from the request thread, you still need a way to provide a response once the processing is complete. This is where Spring's DeferredResult comes in.

When a request is made, you immediately return a DeferredResult from your controller. This object holds the eventual response and is updated once the asynchronous task finishes. Here’s how you can use it:

@RestController
public class AsyncController {

    @Autowired
    private AsyncService asyncService;

    @GetMapping("/process")
    public DeferredResult<ResponseEntity<String>> process(@RequestParam(value = "input", defaultValue = "default") String input) {
        // Set a timeout of 5000ms (5 seconds)
        DeferredResult<ResponseEntity<String>> deferredResult = new DeferredResult<>(5000L);

        CompletableFuture<String> futureResult = asyncService.processTask(input);
        futureResult.thenAccept(result -> 
            deferredResult.setResult(ResponseEntity.ok(result))
        ).exceptionally(ex -> {
            deferredResult.setErrorResult(
                ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                              .body("Error processing request")
            );
            return null;
        });

        return deferredResult;
    }
}
Enter fullscreen mode Exit fullscreen mode

With this pattern:

  • Individual Request Contexts: Each HTTP request creates a unique DeferredResult.
  • Asynchronous Invocation: The heavy processing runs on a separate thread (or virtual thread, as we'll see later) and updates its corresponding DeferredResult when done.
  • Timeout Management: If processing exceeds the 5-second timeout, you can handle it gracefully.

Customizing Thread Pools for Heavy Loads

Under heavy loads, the choice of thread pool is critical. Configuring a custom Executor lets you fine-tune the number of threads, queue capacity, and more:

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);   // Minimum threads
        executor.setMaxPoolSize(50);    // Maximum threads
        executor.setQueueCapacity(100); // Queue capacity before new threads are created
        executor.setThreadNamePrefix("Async-");
        executor.initialize();
        return executor;
    }
}
Enter fullscreen mode Exit fullscreen mode

This custom executor ensures that your asynchronous tasks are efficiently scheduled and executed, even under the strain of a million requests. If you don’t configure an executor that leverages virtual threads (as we’ll cover next), your code will run on these heavier platform threads.


Virtual Threads: Simplifying Blocking Code

Traditional asynchronous design forces you to avoid blocking calls or rewrite your code in a non-blocking, reactive style. However, not every use case requires a fully reactive model. With virtual threads—a feature introduced with Project Loom—you can write blocking code in a natural, synchronous style while still achieving high concurrency.

What Are Virtual Threads?

  • Lightweight Concurrency: Virtual threads are much lighter than platform threads, allowing you to create thousands or even millions without incurring significant overhead.
  • Simplified Coding: You can write blocking operations (like Thread.sleep()) directly. Virtual threads manage the underlying blocking efficiently.

Configuring a Virtual Thread Executor

Here’s how to create an executor that uses virtual threads:

@Configuration
public class AsyncConfig {

    @Bean(name = "virtualThreadExecutor")
    public Executor virtualThreadExecutor() {
        // Creates an executor that spawns a new virtual thread per task.
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}
Enter fullscreen mode Exit fullscreen mode

Using Virtual Threads in Your Service

You simply annotate your service method to use the virtual thread executor:

@Service
public class AsyncService {

    @Async("virtualThreadExecutor")
    public CompletableFuture<String> processTask(String input) {
        try {
            // Blocking code written in a natural style
            Thread.sleep(3000); // 3-second delay
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.completedFuture("Processing interrupted");
        }
        return CompletableFuture.completedFuture("Processed input: " + input);
    }
}
Enter fullscreen mode Exit fullscreen mode

This means that if you don't explicitly use virtual threads, your asynchronous tasks will run on traditional platform threads. Virtual threads let you write blocking code without worrying about the resource overhead typically associated with them.


Complete Example

Below is a complete Spring Boot project example that combines everything we've discussed—from asynchronous processing with DeferredResult to leveraging virtual threads for scalable blocking code.

Main Application Class

package com.example.asyncdemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class AsyncDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(AsyncDemoApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode

Virtual Thread Executor Configuration

package com.example.asyncdemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

@Configuration
public class AsyncConfig {

    @Bean(name = "virtualThreadExecutor")
    public Executor virtualThreadExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}
Enter fullscreen mode Exit fullscreen mode

Asynchronous Service with Blocking Code

package com.example.asyncdemo.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;

@Service
public class AsyncService {

    @Async("virtualThreadExecutor")
    public CompletableFuture<String> processTask(String input) {
        try {
            Thread.sleep(3000); // Simulate blocking call
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.completedFuture("Processing interrupted");
        }
        return CompletableFuture.completedFuture("Processed input: " + input);
    }
}
Enter fullscreen mode Exit fullscreen mode

REST Controller Using DeferredResult

package com.example.asyncdemo.controller;

import com.example.asyncdemo.service.AsyncService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import java.util.concurrent.CompletableFuture;

@RestController
public class AsyncController {

    @Autowired
    private AsyncService asyncService;

    @GetMapping("/process")
    public DeferredResult<ResponseEntity<String>> process(@RequestParam(value = "input", defaultValue = "default") String input) {
        // Configure DeferredResult with a 5-second timeout
        DeferredResult<ResponseEntity<String>> deferredResult = new DeferredResult<>(5000L);

        CompletableFuture<String> futureResult = asyncService.processTask(input);
        futureResult.thenAccept(result -> 
            deferredResult.setResult(ResponseEntity.ok(result))
        ).exceptionally(ex -> {
            deferredResult.setErrorResult(
                ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                              .body("Error processing request")
            );
            return null;
        });

        return deferredResult;
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this post, we've explored how to build a scalable backend service with Spring Boot that can handle massive loads asynchronously. By leveraging:

  • DeferredResult to maintain unique request contexts,
  • @Async to decouple heavy processing from the request thread,
  • Custom executors to manage thread pools, and
  • Virtual threads to write natural blocking code while scaling concurrency,

you can design a robust service that responds promptly even under high load. If you choose not to use virtual threads, your asynchronous tasks will run on traditional platform threads, which are heavier. Virtual threads offer a game-changing approach by simplifying your code without sacrificing performance.

Happy coding, and may your backends scale gracefully!

Top comments (0)

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay