Production · Lesson 83 of 95
Async Processing with @Async
Async Processing with @Async in Spring Boot: run slow work in the background, return CompletableFuture, tune the thread pool and avoid the same-class trap.
You order a thali at a busy restaurant. The waiter takes your order, walks to the kitchen and stands there until the food is ready. Meanwhile, five other tables wait for him. That is a poor way to run a restaurant. A smart waiter hands the order to the kitchen and comes back at once to serve others. When the food is ready, the kitchen calls him. Async processing with @Async gives your Spring Boot code the same trick: hand slow work to another thread, and carry on with the next customer.
In this guide you will build a food delivery order flow where SMS and email run in the background, and you will learn how to get results back, how to size the thread pool, and where the common traps are.
What is Async Processing with @Async?
Normally, Java runs one line after another on the same thread. If a method takes three seconds to send an email, the caller is stuck for three seconds. With @Async, the caller gets control back almost at once, and the slow method carries on in the background.
An async method can return:
void, when you do not need an answer (fire and forget).CompletableFuture<T>, when you want the result later.
Why is it used?
- Faster responses. A user placing an order should not wait for an SMS gateway.
- Better use of time. Two slow tasks that do not depend on each other can run together.
- Slow partners. Email servers, payment checks and reports often take seconds. Async keeps your request threads free.
- Simple to add. One annotation, not a hand-written thread pool.
Async does not make the work itself faster. It lets waiting happen in the background, so the caller does not wait too.
How it works
Spring wraps your bean in a proxy, as it does for caching and security. When someone calls an @Async method, the proxy does not run it. It hands the call to a task executor, which is a pool of worker threads.
textCaller thread (main) | | sendSms(101) v +------------------------+ | Async proxy | | submit to the pool | +------------------------+ | \ | returns at once \ v v Caller carries on +-----------+ | Worker | | task-1 | | sends SMS | +-----------+
The caller continues immediately. The worker thread runs the method body and, if the method returns a CompletableFuture, fills in the answer when done. Spring Boot creates the executor for you. Its threads are named with the prefix task-, which is handy when you read logs.
Real-Life Example
A cloud kitchen gets an order on the app. The cashier confirms payment, and then three things must happen: tell the customer by SMS, email the receipt, and tell the rider. None of these need to finish before the cashier takes the next order. So the cashier starts all three helpers and moves on. The cashier only waits at the end of the day, when he counts how many messages went out.
Code Example
Let's build QuickBite. Sending an SMS and an email each take one second. We run both in the background and measure the total time.
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.quickbite</groupId> <artifactId>orders</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn
File: OrdersApplication.java in package com.quickbite.orders
javapackage com.quickbite.orders; import java.util.concurrent.CompletableFuture; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableAsync public class OrdersApplication { public static void main(String[] args) { SpringApplication.run(OrdersApplication.class, args); } @Bean CommandLineRunner demo(NotificationService notifications) { return args -> { long start = System.currentTimeMillis(); System.out.println("Order 101 placed on thread: " + Thread.currentThread().getName()); CompletableFuture<String> sms = notifications.sendSms(101); CompletableFuture<String> email = notifications.sendEmail(101); System.out.println("Order accepted, customer is not waiting"); System.out.println(sms.join()); System.out.println(email.join()); long took = System.currentTimeMillis() - start; System.out.println("Both finished in under 1800 ms: " + (took < 1800)); }; } }
File: NotificationService.java in package com.quickbite.orders
javapackage com.quickbite.orders; import java.util.concurrent.CompletableFuture; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @Service public class NotificationService { @Async public CompletableFuture<String> sendSms(int orderId) { pause(); return CompletableFuture.completedFuture( "SMS for order " + orderId + " ran on a pool thread: " + onPoolThread()); } @Async public CompletableFuture<String> sendEmail(int orderId) { pause(); return CompletableFuture.completedFuture( "Email for order " + orderId + " ran on a pool thread: " + onPoolThread()); } private boolean onPoolThread() { return Thread.currentThread().getName().startsWith("task-"); } private void pause() { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }
Run it with ./mvnw spring-boot:run.
Output:
textOrder 101 placed on thread: main Order accepted, customer is not waiting SMS for order 101 ran on a pool thread: true Email for order 101 ran on a pool thread: true Both finished in under 1800 ms: true
Some lines are longer on screen. They are wrapped here to fit small screens.
Code Explained
@EnableAsyncswitches the feature on. Without it, the methods run on the caller's thread and the app behaves as if@Asyncwas not there.- The first line shows the order was placed on
main. The SMS and email ran on pool threads. Thetruein each message means the thread name started withtask-, which is the name Spring Boot gives its default async pool. - Each method sleeps one second, so running one after the other would take two seconds. Together they finished in under 1800 ms, which proves they ran side by side.
CompletableFuture<String>is the box that holds the answer.join()waits for the answer when we finally need it. The method returnscompletedFuture(...)because the work is already done inside the background thread.- The line "Order accepted, customer is not waiting" printed before the SMS finished. That shows the caller was free.
- If a method only returns
void, the caller has no way to wait for it or to see its result.
Sizing the Thread Pool
Spring Boot's default pool is fine for learning. In a busy app, tune it in application.properties.
propertiesspring.task.execution.pool.core-size=4 spring.task.execution.pool.max-size=16 spring.task.execution.pool.queue-capacity=100 spring.task.execution.thread-name-prefix=quickbite-
The pool keeps four threads ready. When they are busy, new tasks wait in a queue of 100. Only when the queue is full does the pool grow towards 16 threads. If everything is full, new tasks are rejected, so set the numbers with real traffic in mind.
Common Mistakes
- Forgetting `@EnableAsync`. Everything runs in the foreground and no error appears.
- Expecting a returned value from `void`. Use
CompletableFuturewhen the caller needs the result. - Lost exceptions. In a
voidasync method, an exception is only logged. Handle errors inside the method, or use anAsyncUncaughtExceptionHandler. - Using it on private methods. Only public calls from other beans go through the proxy.
- Assuming the security user or transaction follows. The new thread does not share the caller's transaction, and by default it does not carry the login either.
- No pool limits. An unbounded flood of tasks can use all your memory. Set a queue size.
Interview Questions
What does `@Async` do?
Ans:It runs the method on a separate thread from a task executor, so the caller does not wait.
What can an `@Async` method return?
Ans:void or a CompletableFuture. Older code also uses Future.
Why does `@Async` sometimes not work?
Ans:@EnableAsync is missing, the method is called from the same class, or the method is not public.
Does an async method share the caller's transaction?
Ans:No. It runs on another thread, so it gets its own transaction if it needs one.
How do you control the number of async threads?
Ans:Set the task execution pool properties, or define your own Executor bean.
Key Points to Remember
@Asyncruns a method in the background, and@EnableAsyncturns it on.- Return
CompletableFuturewhen you need a result, andvoidfor fire and forget. - Calls inside the same class skip the proxy and stay synchronous.
- Errors in
voidasync methods need special handling. - Set pool size and queue size for real traffic.
Frequently Asked Questions
Is async processing with @Async the same as multithreading?
It uses threads, but Spring manages them for you. You write a normal method and the framework runs it on a pool.
When should I not use @Async?
When the caller needs the result right away to continue, or when the work must happen in the same transaction.
How is @Async different from @Scheduled?
@Scheduled decides when a method runs. @Async decides which thread runs it. They can be combined, so a timed job does not block others.
Do I need Kafka or a queue instead?
For simple background work inside one app, @Async is enough. If work must survive a restart or cross services, use a real message queue.
Related Topics
- Scheduling with @Scheduled: timed jobs that pair well with async work.
- Spring Events: announce something happened, and let listeners react in the background.
- Sending Email: a classic slow task to move off the request thread.
- Spring AOP: the proxy idea behind
@Async.
Practice Problems
Try each problem on your own first. Both are command line programs.
Easy: Lab Report in the Background
City Lab takes two seconds to build a blood test report. Write an @Async method buildReport(patient) that returns a CompletableFuture<String>. In the runner, print Report requested for Anil right after calling it, then wait for the answer and print it.
Show answerHide answer
join() waits for the background thread and prints the finished report.File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.citylab</groupId> <artifactId>reports</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn
File: ReportService.java in package com.citylab.reports
javapackage com.citylab.reports; import java.util.concurrent.CompletableFuture; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @Service public class ReportService { @Async public CompletableFuture<String> buildReport(String patient) { try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return CompletableFuture.completedFuture("Report ready for " + patient); } }
File: ReportsApplication.java in package com.citylab.reports
javapackage com.citylab.reports; import java.util.concurrent.CompletableFuture; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableAsync public class ReportsApplication { public static void main(String[] args) { SpringApplication.run(ReportsApplication.class, args); } @Bean CommandLineRunner demo(ReportService reports) { return args -> { CompletableFuture<String> report = reports.buildReport("Anil"); System.out.println("Report requested for Anil"); System.out.println(report.join()); }; } }
Output:
textReport requested for Anil Report ready for Anil
Medium: Refund Pool with Error Handling
PayEasy processes refunds in the background on its own pool of two threads named refund-1, refund-2. A refund of a zero or negative amount must fail with the message Amount must be positive, and the caller must print DONE or FAILED with the reason, without the app crashing.
Show answerHide answer
CompletableFuture and throws, Spring completes the future with that error, and handle turns it into a message.File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.payeasy</groupId> <artifactId>refunds</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn
File: RefundApplication.java in package com.payeasy.refunds
javapackage com.payeasy.refunds; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @SpringBootApplication @EnableAsync public class RefundApplication { public static void main(String[] args) { SpringApplication.run(RefundApplication.class, args); } @Bean(name = "refundExecutor") Executor refundExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(2); executor.setQueueCapacity(20); executor.setThreadNamePrefix("refund-"); executor.setDaemon(true); executor.initialize(); return executor; } @Bean CommandLineRunner demo(RefundService refunds) { return args -> { for (int amount : new int[] {500, -5}) { String result = refunds.refund(amount) .handle((ok, error) -> error == null ? "Refund " + amount + ": DONE on " + ok : "Refund " + amount + ": FAILED (" + error.getMessage() + ")") .join(); System.out.println(result); } }; } }
File: RefundService.java in package com.payeasy.refunds
javapackage com.payeasy.refunds; import java.util.concurrent.CompletableFuture; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @Service public class RefundService { @Async("refundExecutor") public CompletableFuture<String> refund(int amount) { if (amount <= 0) { throw new IllegalArgumentException("Amount must be positive"); } String pool = Thread.currentThread().getName().startsWith("refund-") ? "refund pool" : "other pool"; return CompletableFuture.completedFuture(pool); } }
Output:
textRefund 500: DONE on refund pool Refund -5: FAILED (java.lang. IllegalArgumentException: Amount must be positive)
The failed line is one line on screen. It is wrapped here to fit small screens.