Java
Spring Boot
Asynchronous Programming
Method Return Type
Thread Management

Async not working for method having return type void

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

@Async can be used on a void method in Spring, so the return type is usually not the real problem. When an async void method appears to run synchronously or not run at all, the cause is almost always proxying, configuration, or error visibility.

Core Sections

void is allowed, but it changes observability

Spring supports async methods returning void, Future, CompletableFuture, and related async types. A void method is fire-and-forget: the caller does not receive a handle for completion, result, or failure.

java
1import org.springframework.scheduling.annotation.Async;
2import org.springframework.stereotype.Service;
3
4@Service
5public class MailService {
6
7    @Async
8    public void sendWelcomeEmail(String email) {
9        System.out.println("Running on thread: " + Thread.currentThread().getName());
10    }
11}

That method can run asynchronously, but only if Spring is actually intercepting the call through an async proxy.

The two requirements people miss

First, async support must be enabled. Second, the method must be called through a Spring-managed bean, not through self-invocation inside the same class.

java
1import org.springframework.context.annotation.Configuration;
2import org.springframework.scheduling.annotation.EnableAsync;
3
4@Configuration
5@EnableAsync
6public class AsyncConfig {
7}

Now consider this service:

java
1import org.springframework.stereotype.Service;
2
3@Service
4public class ReportService {
5    public void generate() {
6        sendAsync();
7    }
8
9    @Async
10    public void sendAsync() {
11        System.out.println("Async?");
12    }
13}

generate() calls sendAsync() on this, so the call never passes through Spring's proxy. The annotation is effectively bypassed. Move the async method to another bean, or inject the proxied bean and call through that.

Why void methods make debugging harder

With CompletableFuture, the caller can wait, chain, or inspect failures. With void, exceptions are not returned to the caller. If your async code throws, the error may only appear in logs, or nowhere obvious if you did not configure an uncaught exception handler.

java
1import java.util.concurrent.Executor;
2import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
3import org.springframework.context.annotation.Bean;
4import org.springframework.scheduling.annotation.AsyncConfigurer;
5import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
6import org.springframework.context.annotation.Configuration;
7
8@Configuration
9public class AsyncExecutorConfig implements AsyncConfigurer {
10
11    @Bean
12    public Executor taskExecutor() {
13        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
14        executor.setCorePoolSize(2);
15        executor.setMaxPoolSize(4);
16        executor.setQueueCapacity(50);
17        executor.setThreadNamePrefix("async-");
18        executor.initialize();
19        return executor;
20    }
21
22    @Override
23    public Executor getAsyncExecutor() {
24        return taskExecutor();
25    }
26
27    @Override
28    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
29        return (ex, method, params) ->
30            System.err.println("Async error in " + method.getName() + ": " + ex.getMessage());
31    }
32}

That setup does not make void wrong, but it makes failures visible.

When to prefer CompletableFuture

If the caller needs to know whether the task finished, failed, or produced a value, void is the wrong interface.

java
1import java.util.concurrent.CompletableFuture;
2import org.springframework.scheduling.annotation.Async;
3import org.springframework.stereotype.Service;
4
5@Service
6public class PricingService {
7
8    @Async
9    public CompletableFuture<Integer> calculateDiscountAsync(int customerId) {
10        int result = customerId * 2;
11        return CompletableFuture.completedFuture(result);
12    }
13}

That makes testing easier and avoids the "it did not work" class of bug where the real issue was simply that the caller had no way to observe the result.

A quick checklist when @Async seems broken

If a void async method looks synchronous, check these items in order:

  1. @EnableAsync exists in configuration.
  2. The method is public.
  3. The bean is managed by Spring.
  4. The call comes from another bean, not from the same instance.
  5. The application has an executor or logs thread names so you can confirm offloading.

Common Pitfalls

  • Blaming the void return type when the real problem is missing @EnableAsync.
  • Calling the async method from another method in the same class and bypassing the proxy.
  • Expecting thrown exceptions to surface in the caller when using fire-and-forget void.
  • Forgetting to configure an executor and then struggling to verify which thread ran the work.
  • Using void when the business flow actually needs completion status or a returned value.

Summary

  • A Spring @Async method can return void; that alone does not disable async behavior.
  • The call must pass through a Spring proxy, which means self-invocation does not work.
  • 'void methods are harder to observe because completion and failure are not returned.'
  • Configure @EnableAsync, a real executor, and exception handling before debugging deeper.
  • Use CompletableFuture when the caller needs a result or reliable failure propagation.

Course illustration
Course illustration

All Rights Reserved.