Spring Boot Async Processing
Some work takes time: sending an email, generating a PDF, or calling a slow external service. If a web request waits for all of it, the user waits too. Async processing lets Spring Boot run that work in the background on another thread, so the request can finish quickly.
This tutorial shows how to switch async support on, use @Async, return results with CompletableFuture, run tasks in parallel, size thread pools, handle errors, and test async code.
1. What Is Asynchronous Processing?
Normally, code runs synchronously: when you call a method, you wait until it finishes before the next line runs. With asynchronous processing, the method starts on a different thread and your code carries on immediately.
| Synchronous | Asynchronous | |
|---|---|---|
| Caller waits? | Yes, until the method returns | No, it continues right away |
| Thread used | The caller’s thread | A separate worker thread |
| Getting the result | Returned directly | Later, through a CompletableFuture |
| Errors | Thrown to the caller | Handled separately |
A real-life example
At a restaurant, the waiter takes your order and passes it to the kitchen. The waiter does not stand in the kitchen until your food is cooked; they go and serve other tables. The kitchen works asynchronously, and the food arrives when it is ready. In Spring Boot, the request thread is the waiter and the async thread pool is the kitchen.
2. Why Use Async Processing?
- Faster responses. Users do not wait for work they do not need to see finish, such as a confirmation email.
- Parallel work. Independent tasks run at the same time instead of one after another.
- More requests handled. Web threads are freed quickly, so the server can accept more users.
- Better resource use. While one thread waits for a slow network call, others keep working.
For example, a dashboard needs data from three services, each taking one second. Called one after another, the page takes 3 seconds. Called in parallel, it takes about 1 second.
3. Common Use Cases
| Use case | Why async helps |
|---|---|
| Sending emails, SMS or push notifications | The user should not wait for the mail server |
| Generating reports, invoices or exports | Large files can take seconds or minutes to build |
| Calling several external APIs | Independent calls can run in parallel |
| Resizing images or processing uploads | Heavy work moves off the request thread |
| Writing audit logs or analytics events | Not part of the user’s main task |
4. Enabling Async Support
Async support is off by default. Switch it on with @EnableAsync on a configuration class:
package com.javabytechie.shop.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}
You can also put it on the main @SpringBootApplication class. Without @EnableAsync, the @Async annotation is silently ignored and every method runs synchronously.
5. Using the @Async Annotation
Add @Async to a method of a Spring bean. When another bean calls it, the method runs on a background thread and the caller continues at once. This style is called fire and forget:
@Service
public class EmailService {
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
@Async
public void sendOrderConfirmation(String email, Long orderId) {
log.info("Sending confirmation for order {} to {}", orderId, email);
// connect to the mail server and send the message (slow)
}
}
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final OrderRepository orderRepository;
private final EmailService emailService;
public OrderService(OrderRepository orderRepository, EmailService emailService) {
this.orderRepository = orderRepository;
this.emailService = emailService;
}
public Order placeOrder(OrderRequest request) {
Order order = orderRepository.save(Order.from(request));
emailService.sendOrderConfirmation(request.email(), order.getId()); // returns immediately
log.info("Order {} placed", order.getId());
return order;
}
}
The log shows two different threads. The order is confirmed on the web thread while the email is sent on a task- thread:
[nio-8080-exec-1] OrderService : Order 1001 placed
[ task-1] EmailService : Sending confirmation for order 1001 to asha@example.com
6. Returning Results with CompletableFuture
When the caller needs the result later, return a CompletableFuture. Wrap the value with CompletableFuture.completedFuture(...), and Spring completes the future the caller receives once the method finishes:
@Service
public class InvoiceService {
@Async
public CompletableFuture<byte[]> generateInvoicePdf(Long orderId) {
byte[] pdf = buildPdf(orderId); // slow work
return CompletableFuture.completedFuture(pdf);
}
}
CompletableFuture<byte[]> invoice = invoiceService.generateInvoicePdf(1001L);
// do other work here while the PDF is being built ...
byte[] pdf = invoice.join(); // wait for the result only when you need it
| Return type | Use it when |
|---|---|
void | You do not need a result (fire and forget) |
CompletableFuture<T> | You need the result later, or want to combine several tasks (recommended) |
Future<T> | Older code; it offers far fewer features than CompletableFuture |
ListenableFuture, mentioned in older tutorials, has been deprecated since Spring Framework 6. Use CompletableFuture instead.
7. Running Tasks in Parallel
This is where async really pays off. A dashboard needs three independent pieces of data. Start all three, then wait for them together:
@Service
public class DashboardDataService {
// orderClient, inventoryClient and paymentClient are injected
// through the constructor (omitted for brevity). Each call takes ~1 second.
@Async
public CompletableFuture<Long> countTodayOrders() {
return CompletableFuture.completedFuture(orderClient.countToday());
}
@Async
public CompletableFuture<Integer> countLowStockItems() {
return CompletableFuture.completedFuture(inventoryClient.countLowStock());
}
@Async
public CompletableFuture<BigDecimal> todayRevenue() {
return CompletableFuture.completedFuture(paymentClient.revenueToday());
}
}
@Service
public class DashboardService {
private final DashboardDataService data;
public DashboardService(DashboardDataService data) {
this.data = data;
}
public Dashboard load() {
CompletableFuture<Long> orders = data.countTodayOrders();
CompletableFuture<Integer> lowStock = data.countLowStockItems();
CompletableFuture<BigDecimal> revenue = data.todayRevenue();
// wait until all three are done
CompletableFuture.allOf(orders, lowStock, revenue).join();
return new Dashboard(orders.join(), lowStock.join(), revenue.join());
}
}
All three calls run at the same time, so the dashboard loads in about one second instead of three. Useful CompletableFuture methods:
| Method | What it does |
|---|---|
allOf(a, b, c) | Completes when all the futures are done |
anyOf(a, b, c) | Completes when the first one is done |
thenApply(fn) | Transforms the result when it arrives |
thenCombine(other, fn) | Combines two results into one |
join() | Waits for the result (unchecked exceptions) |
8. The Default Executor in Spring Boot
You do not have to create a thread pool to get started. Spring Boot auto-configures one, named applicationTaskExecutor, and @Async uses it. Its threads are named task-1, task-2 and so on. Tune it in application.properties:
spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=16
spring.task.execution.pool.queue-capacity=200
spring.task.execution.thread-name-prefix=async-
spring.task.execution.shutdown.await-termination=true
spring.task.execution.shutdown.await-termination-period=30s
| Property | Meaning | Default |
|---|---|---|
pool.core-size | Threads kept ready | 8 |
pool.max-size | Upper limit of threads | Unlimited |
pool.queue-capacity | Tasks that can wait for a free thread | Unlimited |
shutdown.await-termination | Let running tasks finish on shutdown | false |
With the default unlimited queue, the pool never grows beyond the core size and tasks can pile up in memory without limit. Always set a queue-capacity in production.
9. Custom Thread Pools
Different kinds of work deserve different pools. If slow report generation shares a pool with emails, a burst of reports can delay every email. Define a separate executor for each kind of work:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("email-");
executor.initialize();
return executor;
}
@Bean(name = "reportExecutor")
public ThreadPoolTaskExecutor reportExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("report-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
Choose the pool by name in @Async:
@Service
public class ReportService {
@Async("reportExecutor")
public CompletableFuture<Report> buildMonthlyReport(YearMonth month) {
return CompletableFuture.completedFuture(createReport(month));
}
}
@Service
public class EmailService {
@Async("emailExecutor")
public void sendOrderConfirmation(String email, Long orderId) { /* ... */ }
}
Important: once you define your own executor beans, Spring Boot stops creating its default applicationTaskExecutor. When several executors exist and none is the default, a plain @Async with no name falls back to SimpleAsyncTaskExecutor, which starts a brand-new thread for every task with no limit. Name one of your executors taskExecutor to make it the default, or always give a name in @Async.
9.1 How a thread pool handles new tasks
- If fewer than core threads are running, a new thread starts the task.
- Otherwise, the task waits in the queue.
- If the queue is full, extra threads are started, up to max.
- If the queue is full and max threads are busy, the task is rejected (see the FAQ for your options).
Notice that the pool only grows beyond its core size after the queue is full. With a very large queue, the extra threads are almost never used.
10. Error Handling
An exception in an async method cannot travel back to the caller in the normal way, because the caller has already moved on. How you handle it depends on the return type.
10.1 Methods that return CompletableFuture
The exception is stored inside the future. Handle it where you use the result:
CompletableFuture<byte[]> invoice = invoiceService.generateInvoicePdf(orderId)
.exceptionally(ex -> {
log.error("Invoice generation failed for order {}", orderId, ex);
return placeholderPdf(); // fallback value
});
Inside the async method, you can also return a failed future explicitly:
@Async
public CompletableFuture<byte[]> generateInvoicePdf(Long orderId) {
try {
return CompletableFuture.completedFuture(buildPdf(orderId));
} catch (PdfException ex) {
return CompletableFuture.failedFuture(ex);
}
}
10.2 Methods that return void
Nobody receives a void result, so by default the exception is only logged. To handle it yourself, register an AsyncUncaughtExceptionHandler by implementing AsyncConfigurer:
@Configuration
@EnableAsync
public class AsyncErrorConfig implements AsyncConfigurer {
private static final Logger log = LoggerFactory.getLogger(AsyncErrorConfig.class);
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async method {} failed with params {}",
method.getName(), Arrays.toString(params), ex);
}
}
The handler is a good place to log, raise an alert, or save the failed task so it can be retried.
11. Passing Context to Async Threads
Some information lives in a ThreadLocal, which belongs to one thread only. A common example is the logging context (MDC), which holds values like a request ID. A new async thread starts with an empty context, so its log lines lose the request ID.
A TaskDecorator copies the context from the calling thread to the worker thread:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> context = MDC.getCopyOfContextMap(); // caller's thread
return () -> {
try {
if (context != null) {
MDC.setContextMap(context); // worker thread
}
task.run();
} finally {
MDC.clear();
}
};
}
}
Spring Boot applies any TaskDecorator bean to its auto-configured executor. For your own pools, call executor.setTaskDecorator(new MdcTaskDecorator()).
@Bean
public TaskDecorator mdcTaskDecorator() {
return new MdcTaskDecorator();
}
12. Virtual Threads
Java 21 introduced virtual threads: very lightweight threads managed by the JVM. You can run thousands of them without worrying about pool sizes. From Spring Boot 3.2, one property switches them on:
spring.threads.virtual.enabled=true
With this setting, @Async methods run on a new virtual thread each time, with no pool or queue to tune.
| Thread pool | Virtual threads | |
|---|---|---|
| Number of threads | Fixed by core and max size | Effectively unlimited |
| Best for | CPU-heavy work, strict limits | Work that mostly waits: HTTP calls, database queries |
| Tuning | Core size, max size, queue | Almost none |
| Requires | Any Java version | Java 21+ and Spring Boot 3.2+ |
Virtual threads remove thread limits, but not limits on what they call. If thousands of tasks hit the same database at once, it may still be overwhelmed, so keep connection pool limits in place.
13. Testing Async Code
Async code finishes “sometime later”, so a test that checks the result immediately may fail at random. Two reliable approaches:
13.1 Wait for the CompletableFuture
@SpringBootTest
class InvoiceServiceTest {
@Autowired
private InvoiceService invoiceService;
@Test
void generatesInvoice() {
byte[] pdf = invoiceService.generateInvoicePdf(1001L)
.orTimeout(5, TimeUnit.SECONDS)
.join();
assertThat(pdf).isNotEmpty();
}
}
13.2 Poll for void methods with Awaitility
For fire-and-forget methods, wait until the expected side effect happens, with a time limit. Add the org.awaitility:awaitility test dependency:
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@MockitoBean
private MailSender mailSender;
@Test
void sendsConfirmationEmail() {
orderService.placeOrder(new OrderRequest("asha@example.com", List.of("BOOK-1")));
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> verify(mailSender).send(any(SimpleMailMessage.class)));
}
}
Avoid Thread.sleep() in tests. It makes them slow when the sleep is too long and unreliable when it is too short.
14. Common Mistakes to Avoid
| Mistake | What goes wrong | Fix |
|---|---|---|
Calling an @Async method from the same class | The call skips the Spring proxy and runs synchronously | Move the method to another bean and inject it |
Forgetting @EnableAsync | @Async is ignored | Add it to a configuration class |
Putting @Async on a private method | The proxy cannot intercept it | Make the method public |
| Using an unlimited queue | Tasks pile up and memory grows | Set queue-capacity |
Ignoring errors in void methods | Failures go unnoticed | Register an AsyncUncaughtExceptionHandler |
| Making tiny, fast methods async | Thread hand-off costs more than it saves | Only use async for slow work |
Calling join() right after starting a task | The caller waits anyway, gaining nothing | Do other work first, or start several tasks together |
15. Async Processing in Real-World Applications
In larger systems, @Async is one of several tools for background work:
@Asyncfor short background tasks inside one application, such as notifications and parallel API calls.- Message brokers like Kafka or RabbitMQ when work must survive a crash or restart, or be shared between services.
@Scheduledfor work that runs at fixed times, such as nightly clean-up.- Async REST endpoints that return
CompletableFuture, so web threads are freed while slow work runs.
A good rule: if losing the task during a restart would be a problem, use a message broker instead of @Async.
16. Summary
Async processing keeps Spring Boot applications fast and responsive by moving slow work off the request thread. Switch it on with @EnableAsync, mark methods with @Async, and return CompletableFuture when you need results or want to run tasks in parallel. Give each kind of work a properly sized pool with a limited queue, handle errors explicitly, carry context with a TaskDecorator, and consider virtual threads on Java 21.
17. Frequently Asked Questions (FAQ)
Q1. Why does my @Async method still run on the caller’s thread?
Log Thread.currentThread().getName() inside the method to confirm, then check:
@EnableAsyncis missing from your configuration.- The call comes from the same class.
this.sendEmail()skips the Spring proxy that starts the new thread. - The method is not
public, or it isstatic. - The object was created with
new, so it is not a Spring bean and has no proxy.
Q2. Can an @Async method return a plain value like String or List?
It compiles, but the caller always receives null. The method has not finished when the call returns, so there is no value to give back yet.
@Async
public String loadName() { return "Asha"; } // caller gets null
@Async
public CompletableFuture<String> loadNameAsync() { // caller gets the value later
return CompletableFuture.completedFuture("Asha");
}
Use void when you need no result, and CompletableFuture<T> when you do.
Q3. Which executor does @Async use when I have several?
Spring picks the executor in this order:
- The bean named in the annotation, such as
@Async("reportExecutor"). - The executor returned by
getAsyncExecutor(), if you implementAsyncConfigurer. - The only
TaskExecutorbean, if there is exactly one. - A bean named
taskExecutor. - Otherwise, a
SimpleAsyncTaskExecutorthat creates a new thread for every task.
To be safe, name your default executor taskExecutor, and name any special-purpose pools explicitly in @Async.
Q4. What happens when the pool and queue are completely full?
The new task is rejected. By default, Spring throws a TaskRejectedException back to the caller. You can choose a different policy:
| Policy | Behavior |
|---|---|
AbortPolicy (default) | Throws an exception; the task is not run |
CallerRunsPolicy | The caller’s thread runs the task itself, which naturally slows down new submissions |
DiscardPolicy | Silently drops the task |
DiscardOldestPolicy | Drops the oldest waiting task and queues the new one |
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
CallerRunsPolicy is a safe choice for most applications, because no work is lost.
Q5. How many threads should my async pool have?
It depends on what the tasks do:
- Waiting work (HTTP calls, database queries, sending emails): threads spend most of their time idle, so you can use more threads than CPU cores, for example 10 to 50.
- CPU-heavy work (image processing, PDF rendering, calculations): use roughly the number of CPU cores. More threads only compete for the same processors.
Start with a modest size and a bounded queue, then adjust based on metrics such as active threads and queue length. Also check what the tasks depend on: 50 threads are pointless if they all share a database connection pool of 10.
Q6. How do I stop waiting for an async result that takes too long?
Add a timeout on the CompletableFuture:
// Fail with a TimeoutException after 3 seconds
CompletableFuture<Price> price = pricingService.fetchPrice(sku)
.orTimeout(3, TimeUnit.SECONDS);
// Or use a fallback value after 3 seconds
CompletableFuture<Price> priceOrDefault = pricingService.fetchPrice(sku)
.completeOnTimeout(Price.unknown(), 3, TimeUnit.SECONDS);
A timeout stops the caller from waiting; it does not stop the background task. Also set timeouts on the slow call itself, such as HTTP client read timeouts.
Q7. How do I get the original exception when an async task fails?
The exception arrives wrapped in another one, depending on how you wait:
future.get()throwsExecutionException.future.join()throwsCompletionException.
In both cases, the real exception is the cause:
try {
Report report = reportService.buildMonthlyReport(month).join();
} catch (CompletionException ex) {
Throwable realError = ex.getCause(); // e.g. ReportDataMissingException
log.error("Report failed: {}", realError.getMessage(), realError);
}
Q8. Does @Transactional work inside an @Async method?
Yes, but the async method runs in its own, separate transaction. Transactions are tied to a thread, so the caller’s transaction does not carry over. This has two consequences:
- The async task may start before the caller’s transaction commits, so it might not see data the caller just saved. Publish an event and handle it with
@TransactionalEventListenerplus@Asyncto run only after the commit. - Pass IDs, not JPA entities, to async methods. A lazy relationship loaded in the new thread fails, because the original session is not available there.
Q9. Why do I get “Scope 'request' is not active” inside an async method?
Request-scoped beans and objects such as HttpServletRequest exist only on the web request thread, and only until the response is sent. An async thread has no request attached.
Read what you need before starting the async task, and pass plain values as parameters:
String tenantId = request.getHeader("X-Tenant-Id"); // read on the request thread
auditService.recordLogin(userId, tenantId); // pass values into the @Async method
Q10. How do I access the logged-in user inside an async method?
Spring Security stores the current user per thread, so an async thread sees no user by default. Wrap your executor so it copies the security context:
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor();
delegate.setCorePoolSize(4);
delegate.setQueueCapacity(100);
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
A simpler option is to pass the user ID or username as a method parameter, which also makes the method easier to test.
Q11. What is the difference between @Async and CompletableFuture.supplyAsync()?
@Async | supplyAsync() | |
|---|---|---|
| Style | Annotation on a bean method | Plain Java call anywhere |
| Thread pool | A Spring-managed executor | The shared JVM ForkJoinPool, unless you pass one |
| Works on same-class calls | No | Yes |
| Uses Spring features | Error handler, task decorators, metrics | Only if you pass a Spring executor |
If you use supplyAsync(), always pass your own executor. The shared JVM pool is small and is also used by parallel streams, so blocking tasks there can slow down unrelated code:
CompletableFuture.supplyAsync(() -> loadPrices(), taskExecutor);
Q12. Should I use @Async or a message broker like Kafka?
@Async | Message broker | |
|---|---|---|
| Where tasks wait | In application memory | On the broker, stored on disk |
| If the app crashes | Queued tasks are lost | Messages survive and are processed later |
| Across services | No, one application only | Yes |
| Setup | One annotation | A broker to install and run |
Use @Async for work you can afford to lose or simply retry, like a welcome email. Use a broker for work that must not be lost, like payment processing.
Q13. What is the difference between @Async and @Scheduled?
@Asyncruns a method in the background when your code calls it.@Scheduledruns a method automatically at set times, such as every 10 minutes, with nobody calling it.
They can work together. Scheduled jobs run one at a time on a single scheduler thread by default, so a slow job can delay the others. Making the scheduled method hand its work to an @Async method keeps the scheduler free.
Q14. Are async tasks executed in the order I submit them?
Not necessarily. With several threads, tasks start roughly in order but can finish in any order, because each runs at its own speed.
If order matters, for example applying updates to the same account, either do the work synchronously, or use a dedicated executor with exactly one thread:
@Bean(name = "accountUpdateExecutor")
public ThreadPoolTaskExecutor accountUpdateExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(1000);
executor.initialize();
return executor;
}
A single thread processes tasks strictly one after another, at the cost of no parallelism.
Q15. What happens to running async tasks when the application shuts down?
By default, the pool is shut down immediately: running tasks are interrupted and queued tasks are thrown away. To let them finish, enable graceful shutdown for the pool:
spring.task.execution.shutdown.await-termination=true
spring.task.execution.shutdown.await-termination-period=30s
For your own pools, call setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(30). Make sure the period fits within the time your platform, such as Kubernetes, allows for shutdown.
Q16. Can a REST controller return a CompletableFuture?
Yes. Spring MVC treats it as an async request: the web thread is released while the work runs, and the response is sent when the future completes:
@GetMapping("/reports/{month}")
public CompletableFuture<Report> getReport(@PathVariable YearMonth month) {
return reportService.buildMonthlyReport(month); // an @Async method
}
The client still waits for the response, but your server can serve other requests meanwhile. Set a limit with spring.mvc.async.request-timeout=30s. For very long jobs, return 202 Accepted with a job ID instead and let the client check back later.
Q17. Can waiting for async results inside another async task cause problems?
Yes, it can cause thread starvation. Imagine a pool of 4 threads. Four tasks each start a sub-task on the same pool and call join() to wait for it. All 4 threads are now busy waiting, no thread is left to run the sub-tasks, and everything hangs.
To avoid it:
- Run sub-tasks on a different executor from the parent tasks.
- Combine futures with
thenComposeorthenCombineinstead of blocking withjoin()inside a task. - Always use timeouts, so a hang becomes a visible error.
Q18. How do I monitor my async thread pools?
With Spring Boot Actuator, thread pool metrics are recorded automatically for ThreadPoolTaskExecutor beans:
| Metric | Tells you |
|---|---|
executor.active | Threads currently running tasks |
executor.queued | Tasks waiting for a free thread |
executor.pool.size | Threads that currently exist |
executor.completed | Tasks finished so far |
A queue that keeps growing means tasks arrive faster than they are processed, so add threads or reduce the work. Use the name tag to check one pool, for example /actuator/metrics/executor.queued?tag=name:reportExecutor.
Q19. Can I cancel an async task that is already running?
Partly. Calling future.cancel(true) marks the future as cancelled, so callers stop waiting. Whether the work actually stops depends on two things:
- For
CompletableFuture,canceldoes not interrupt the running thread; the task carries on in the background. - Even with a plain
Future, an interrupt only works if the task checks for it.
For long tasks, check a stop signal regularly:
@Async
public CompletableFuture<Integer> importRows(List<Row> rows) {
int imported = 0;
for (Row row : rows) {
if (Thread.currentThread().isInterrupted() || importCancelled.get()) {
break; // stop cleanly
}
save(row);
imported++;
}
return CompletableFuture.completedFuture(imported);
}
Q20. Should I make every slow method @Async?
No. Async adds complexity: errors are harder to trace, tests need waiting logic, and context such as transactions does not follow the work. Use it when:
- The user does not need the result to continue, like sending a receipt.
- Several independent slow calls can run in parallel.
- The work is genuinely slow, taking hundreds of milliseconds or more.
Keep work synchronous when the next step depends on its result, when it must be part of the same transaction, or when it is already fast. If a method is slow because of an inefficient query, fix the query first.