Introduction
Spring's TaskExecutor runs tasks asynchronously but does not directly tell you when a task finishes. To know when a thread is finished, use @Async with a Future or CompletableFuture return type, use ListenableFuture (Spring 5) or CompletableFuture (Spring 6+) with callbacks, or submit Callable tasks to an AsyncTaskExecutor and call .get() on the returned Future. For waiting on multiple tasks, use CompletableFuture.allOf().
Method 1: @Async with CompletableFuture
The cleanest approach — annotate a method with @Async and return a CompletableFuture:
1@Service
2public class DataService {
3
4 @Async
5 public CompletableFuture<String> fetchData(String url) {
6 // Runs in a separate thread
7 String result = restTemplate.getForObject(url, String.class);
8 return CompletableFuture.completedFuture(result);
9 }
10}
11
12@RestController
13public class DataController {
14
15 @Autowired
16 private DataService dataService;
17
18 @GetMapping("/data")
19 public String getData() throws Exception {
20 CompletableFuture<String> future = dataService.fetchData("https://api.example.com");
21
22 // Block until done
23 String result = future.get(); // Waits for completion
24
25 // Or with timeout
26 String result2 = future.get(5, TimeUnit.SECONDS);
27
28 return result;
29 }
30}
Enable async support in your configuration:
1@Configuration
2@EnableAsync
3public class AsyncConfig {
4
5 @Bean
6 public Executor taskExecutor() {
7 ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
8 executor.setCorePoolSize(5);
9 executor.setMaxPoolSize(10);
10 executor.setQueueCapacity(25);
11 executor.setThreadNamePrefix("async-");
12 executor.initialize();
13 return executor;
14 }
15}
Method 2: CompletableFuture Callbacks
Use callbacks instead of blocking with .get():
1@Service
2public class ProcessingService {
3
4 @Async
5 public CompletableFuture<Integer> processItem(int id) {
6 int result = heavyComputation(id);
7 return CompletableFuture.completedFuture(result);
8 }
9}
10
11// Usage with callbacks
12CompletableFuture<Integer> future = processingService.processItem(42);
13
14future.thenAccept(result -> {
15 System.out.println("Task completed with result: " + result);
16});
17
18future.exceptionally(ex -> {
19 System.err.println("Task failed: " + ex.getMessage());
20 return null;
21});
Method 3: Waiting for Multiple Tasks
1@Service
2public class BatchService {
3
4 @Autowired
5 private DataService dataService;
6
7 public List<String> fetchAll(List<String> urls) throws Exception {
8 // Launch all tasks concurrently
9 List<CompletableFuture<String>> futures = urls.stream()
10 .map(dataService::fetchData)
11 .collect(Collectors.toList());
12
13 // Wait for all to complete
14 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
15
16 // Collect results
17 return futures.stream()
18 .map(CompletableFuture::join)
19 .collect(Collectors.toList());
20 }
21}
Method 4: Submit Callable to TaskExecutor
For lower-level control without @Async:
1@Service
2public class TaskService {
3
4 @Autowired
5 private AsyncTaskExecutor taskExecutor;
6
7 public Future<String> submitTask(String input) {
8 return taskExecutor.submit(() -> {
9 Thread.sleep(2000); // Simulate work
10 return "Processed: " + input;
11 });
12 }
13
14 public void runAndWait() throws Exception {
15 Future<String> future = submitTask("data");
16
17 // Poll for completion
18 while (!future.isDone()) {
19 System.out.println("Waiting...");
20 Thread.sleep(500);
21 }
22
23 String result = future.get();
24 System.out.println(result);
25 }
26}
Method 5: CountDownLatch for Fire-and-Forget Tasks
When you need to wait for void tasks that do not return a value:
1@Service
2public class NotificationService {
3
4 @Autowired
5 private TaskExecutor taskExecutor;
6
7 public void sendNotifications(List<String> recipients) throws InterruptedException {
8 CountDownLatch latch = new CountDownLatch(recipients.size());
9
10 for (String recipient : recipients) {
11 taskExecutor.execute(() -> {
12 try {
13 sendEmail(recipient);
14 } finally {
15 latch.countDown();
16 }
17 });
18 }
19
20 // Wait for all notifications to complete (with timeout)
21 boolean completed = latch.await(30, TimeUnit.SECONDS);
22 if (!completed) {
23 System.err.println("Some notifications timed out");
24 }
25 }
26}
Method 6: TaskExecutor with TaskDecorator
Add completion logging via a decorator:
1@Configuration
2@EnableAsync
3public class AsyncConfig {
4
5 @Bean
6 public Executor taskExecutor() {
7 ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
8 executor.setCorePoolSize(5);
9 executor.setMaxPoolSize(10);
10 executor.setTaskDecorator(runnable -> () -> {
11 long start = System.currentTimeMillis();
12 try {
13 runnable.run();
14 } finally {
15 long elapsed = System.currentTimeMillis() - start;
16 System.out.println("Task finished in " + elapsed + "ms");
17 }
18 });
19 executor.initialize();
20 return executor;
21 }
22}
Common Pitfalls
Calling an @Async method from within the same class: Spring's @Async works through proxies. If you call an async method from another method in the same bean, the proxy is bypassed and the method runs synchronously. Extract the async method into a separate @Service bean.
Not enabling @EnableAsync in the configuration: Without @EnableAsync, the @Async annotation is ignored and all methods run synchronously on the caller's thread. Add @EnableAsync to a @Configuration class.
Ignoring the returned Future from an @Async method: If an @Async method returns CompletableFuture but the caller ignores it, exceptions thrown in the async method are silently swallowed. Always handle the Future or configure an AsyncUncaughtExceptionHandler.
Blocking the main thread with future.get() without a timeout: Calling future.get() blocks indefinitely if the task never completes. Always use future.get(timeout, TimeUnit.SECONDS) or use non-blocking callbacks with thenAccept().
Not configuring a custom ThreadPoolTaskExecutor: Without a custom executor, Spring uses SimpleAsyncTaskExecutor, which creates a new thread for every task with no pooling or queue limit. This can exhaust system resources under load. Always define a ThreadPoolTaskExecutor bean.
Summary
Return CompletableFuture from @Async methods to track completion
Use future.get() to block until done, or thenAccept() for non-blocking callbacks
Use CompletableFuture.allOf() to wait for multiple concurrent tasks
Use CountDownLatch for fire-and-forget void tasks that need synchronization
Always configure a ThreadPoolTaskExecutor with bounded pool size and queue capacity