00:00

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.

SynchronousAsynchronous
Caller waits?Yes, until the method returnsNo, it continues right away
Thread usedThe caller’s threadA separate worker thread
Getting the resultReturned directlyLater, through a CompletableFuture
ErrorsThrown to the callerHandled 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 caseWhy async helps
Sending emails, SMS or push notificationsThe user should not wait for the mail server
Generating reports, invoices or exportsLarge files can take seconds or minutes to build
Calling several external APIsIndependent calls can run in parallel
Resizing images or processing uploadsHeavy work moves off the request thread
Writing audit logs or analytics eventsNot 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:

AsyncConfig.java
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:

EmailService.java
@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)
    }
}
OrderService.java
@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:

Console
[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:

InvoiceService.java
@Service
public class InvoiceService {

    @Async
    public CompletableFuture<byte[]> generateInvoicePdf(Long orderId) {
        byte[] pdf = buildPdf(orderId); // slow work
        return CompletableFuture.completedFuture(pdf);
    }
}
Caller
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 typeUse it when
voidYou 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:

DashboardDataService.java
@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());
    }
}
DashboardService.java
@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:

MethodWhat 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:

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
PropertyMeaningDefault
pool.core-sizeThreads kept ready8
pool.max-sizeUpper limit of threadsUnlimited
pool.queue-capacityTasks that can wait for a free threadUnlimited
shutdown.await-terminationLet running tasks finish on shutdownfalse

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:

AsyncConfig.java
@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:

ReportService.java
@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

  1. If fewer than core threads are running, a new thread starts the task.
  2. Otherwise, the task waits in the queue.
  3. If the queue is full, extra threads are started, up to max.
  4. 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:

Caller
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:

InvoiceService.java
@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:

AsyncErrorConfig.java
@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:

MdcTaskDecorator.java
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()).

AsyncConfig.java
@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:

application.properties
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 poolVirtual threads
Number of threadsFixed by core and max sizeEffectively unlimited
Best forCPU-heavy work, strict limitsWork that mostly waits: HTTP calls, database queries
TuningCore size, max size, queueAlmost none
RequiresAny Java versionJava 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

InvoiceServiceTest.java
@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:

OrderServiceTest.java
@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

MistakeWhat goes wrongFix
Calling an @Async method from the same classThe call skips the Spring proxy and runs synchronouslyMove the method to another bean and inject it
Forgetting @EnableAsync@Async is ignoredAdd it to a configuration class
Putting @Async on a private methodThe proxy cannot intercept itMake the method public
Using an unlimited queueTasks pile up and memory growsSet queue-capacity
Ignoring errors in void methodsFailures go unnoticedRegister an AsyncUncaughtExceptionHandler
Making tiny, fast methods asyncThread hand-off costs more than it savesOnly use async for slow work
Calling join() right after starting a taskThe caller waits anyway, gaining nothingDo 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:

  • @Async for 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.
  • @Scheduled for 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:

  1. @EnableAsync is missing from your configuration.
  2. The call comes from the same class. this.sendEmail() skips the Spring proxy that starts the new thread.
  3. The method is not public, or it is static.
  4. 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:

  1. The bean named in the annotation, such as @Async("reportExecutor").
  2. The executor returned by getAsyncExecutor(), if you implement AsyncConfigurer.
  3. The only TaskExecutor bean, if there is exactly one.
  4. A bean named taskExecutor.
  5. Otherwise, a SimpleAsyncTaskExecutor that 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:

PolicyBehavior
AbortPolicy (default)Throws an exception; the task is not run
CallerRunsPolicyThe caller’s thread runs the task itself, which naturally slows down new submissions
DiscardPolicySilently drops the task
DiscardOldestPolicyDrops 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() throws ExecutionException.
  • future.join() throws CompletionException.

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 @TransactionalEventListener plus @Async to 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()?
@AsyncsupplyAsync()
StyleAnnotation on a bean methodPlain Java call anywhere
Thread poolA Spring-managed executorThe shared JVM ForkJoinPool, unless you pass one
Works on same-class callsNoYes
Uses Spring featuresError handler, task decorators, metricsOnly 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?
@AsyncMessage broker
Where tasks waitIn application memoryOn the broker, stored on disk
If the app crashesQueued tasks are lostMessages survive and are processed later
Across servicesNo, one application onlyYes
SetupOne annotationA 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?
  • @Async runs a method in the background when your code calls it.
  • @Scheduled runs 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 thenCompose or thenCombine instead of blocking with join() 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:

MetricTells you
executor.activeThreads currently running tasks
executor.queuedTasks waiting for a free thread
executor.pool.sizeThreads that currently exist
executor.completedTasks 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, cancel does 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.