Async & Scheduled Tasks
Reviewed & published by Brayan K
Offload slow work with @EnableAsync + @Async (returning CompletableFuture), and run jobs on a timer with @EnableScheduling + @Scheduled.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should understand Spring beans and proxies, and ideally have met CompletableFuture. Having documented your API with OpenAPI & springdoc rounds out the service.
What You'll Learn
- @EnableAsync + @Async to offload work
- Returning CompletableFuture<T> from async methods
- Configuring a ThreadPoolTaskExecutor
- @EnableScheduling + @Scheduled (fixedRate/fixedDelay/cron)
- Exception handling in async code
- The self-invocation pitfall (the proxy is bypassed)
The Big Idea — Hand It to a Helper, Don't Wait
💡 Analogy: In a busy kitchen the head chef does not stand and watch the dishwasher run. They hand it off and carry on. @Async is that hand-off: slow, independent work goes to a pool of helper threads while the request thread is freed up. @Scheduled is the kitchen timer — jobs that fire on their own at set times.
You decide what to offload and when things should fire; Spring's executors and scheduler do the rest.
1️⃣ Enable Async & Configure a Thread Pool
Turn on support with @EnableAsync and @EnableScheduling, and define a ThreadPoolTaskExecutor bean to control pool size and naming.
// (Conceptual — needs Spring on the classpath to run.)
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync // turns on @Async support
@EnableScheduling // turns on @Scheduled support
public class AsyncConfig {
// A CONFIGURED thread pool for async work (size, queue, thread names).
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100);
ex.setThreadNamePrefix("async-");
ex.initialize();
return ex;
}
}2️⃣ Offloading Work with @Async
Annotate a method @Async. Return CompletableFuture<T> when you need the result, or void for fire-and-forget.
package com.example.demo;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
public class ReportService {
// Runs on a pool thread; caller returns IMMEDIATELY with a future.
@Async
public CompletableFuture<String> generate(long id) {
// ... slow, independent work: external call, heavy computation ...
String result = "report-" + id;
return CompletableFuture.completedFuture(result);
}
// Fire-and-forget: void @Async. The caller cannot catch its exception;
// an AsyncUncaughtExceptionHandler deals with failures instead.
@Async
public void sendEmail(String to) {
// ... send the email off the request thread ...
}
}3️⃣ Running Jobs with @Scheduled
fixedRate measures from each start, fixedDelay from each finish, and cron gives calendar-style schedules.
package com.example.demo;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class Jobs {
// Every 5s, measured from each START (overlaps if a run is slow).
@Scheduled(fixedRate = 5000)
public void poll() { /* ... */ }
// 5s AFTER the previous run FINISHES (never overlaps).
@Scheduled(fixedDelay = 5000)
public void cleanup() { /* ... */ }
// Calendar-style: 8:00 every weekday (cron: sec min hour dom mon dow).
@Scheduled(cron = "0 0 8 * * MON-FRI")
public void dailyReport() { /* ... */ }
}4️⃣ The Self-Invocation Pitfall
@Async works via a proxy. Calling an async method from within the same class skips the proxy, so it runs synchronously. Call it through another bean instead.
package com.example.demo;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
public void placeOrder() {
save();
// PITFALL: internal self-call goes to the raw object, NOT the proxy,
// so @Async is BYPASSED and notify() runs synchronously.
notify("user@example.com"); // <-- runs on THIS thread
// FIX: call it through another bean (or self-injected proxy) so the
// proxy intercepts the call and @Async actually applies.
}
@Async
public void notify(String to) { /* ... */ }
private void save() { /* ... */ }
}5️⃣ The Idea, Concretely
To make the hand-off tangible, here is a plain-Java stand-in using a thread pool and CompletableFuture — exactly the model @Async builds on — with real output.
import java.util.concurrent.*;
public class Main {
// A plain-Java stand-in for the IDEA behind @Async: hand work to a
// thread pool and return a CompletableFuture the caller can wait on.
static ExecutorService pool = Executors.newFixedThreadPool(2);
static CompletableFuture<String> generate(long id) {
return CompletableFuture.supplyAsync(() -> "report-" + id, pool);
}
public static void main(String[] args) throws Exception {
System.out.println("submitting work, not blocking...");
CompletableFuture<String> f1 = generate(1);
CompletableFuture<String> f2 = generate(2);
// Compose / wait on results, just like an @Async return value.
System.out.println("got: " + f1.get() + ", " + f2.get());
pool.shutdown();
System.out.println("done");
}
}// 🎯 YOUR TURN — replace each ___ using the hint beside it.
import java.util.*;
import java.util.concurrent.*;
public class Main {
static String slowLookup(String key) {
return key.toUpperCase() + "-ok";
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(3);
List<String> keys = List.of("alpha", "beta", "gamma");
List<CompletableFuture<String>> futures = new ArrayList<>();
for (String k : keys) {
// 1) Starts the work on the pool and hands back a future at once.
futures.add(CompletableFuture.___(() -> slowLookup(k), pool)); // 👉 replace ___ with supplyAsync
}
// 2) Wait until every one of them has finished.
CompletableFuture.___(futures.toArray(new CompletableFuture[0])).join(); // 👉 replace ___ with allOf
List<String> results = new ArrayList<>();
// 3) Read them back in the order they were SUBMITTED, not the order
// they finished — which is a race and must never be claimed.
for (CompletableFuture<String> f : futures) results.add(f.___()); // 👉 replace ___ with get
System.out.println("In submission order: " + results);
CompletableFuture<String> chained = CompletableFuture
.supplyAsync(() -> "raw", pool)
// 4) Transform the result when it arrives, without blocking.
.___(String::toUpperCase) // 👉 replace ___ with thenApply
.thenApply(s -> s + "!");
System.out.println("Chained: " + chained.get());
CompletableFuture<String> failed = CompletableFuture
.<String>supplyAsync(() -> { throw new IllegalStateException("no cache"); }, pool)
// 5) The recovery path: turn a failure into a usable value.
.___(e -> "fell back because: " + e.getCause().getMessage()); // 👉 replace ___ with exceptionally
System.out.println(failed.get());
pool.shutdown();
System.out.println("Pool shut down: " + pool.awaitTermination(5, TimeUnit.SECONDS));
}
}🧠 Quick Recall
Predict before you peek.
Answer: a CompletableFuture<T> (or the older Future<T>).
Answer: fixedRate measures from each start; fixedDelay from each finish (so it never overlaps).
Answer: the self-invocation bypasses the Spring proxy that adds the async behaviour.
Common Errors
- ❌ Forgetting @EnableAsync. @Async is ignored. Fix: add it to a config class.
- ❌ Calling @Async from the same class. Proxy bypassed. Fix: call through another bean.
- ❌ Try/catch around a void @Async call. Catches nothing. Fix: use an AsyncUncaughtExceptionHandler.
- ❌ Confusing fixedRate with fixedDelay. Overlapping runs. Fix: use fixedDelay when runs must not overlap.
- ❌ Forgetting @EnableScheduling. @Scheduled never fires. Fix: add it to a config class.
Pro Tips
- 💡 Always configure your own ThreadPoolTaskExecutor — the default simple executor is unbounded and risky.
- 💡 Return CompletableFuture whenever you care about the result or its failure.
- 💡 Prefer fixedDelay for slow jobs so a long run cannot pile up on the next.
- 💡 Offload only slow, independent work — async adds overhead and complexity, so do not wrap trivial calls.
📋 Quick Reference
| Feature | Annotation / type | Note |
|---|---|---|
| enable async | @EnableAsync | master switch |
| offload | @Async | runs on a pool thread |
| result | CompletableFuture<T> | wait/compose |
| pool | ThreadPoolTaskExecutor | size/queue/names |
| enable timer | @EnableScheduling | for @Scheduled |
| schedule | @Scheduled(fixedRate/fixedDelay/cron) | interval or cron |
📚 Resources / Keep Learning
- 📘 Creating Asynchronous Methods — spring.io guide
- 📗 Scheduling Tasks — spring.io guide
- 📗 Task Execution and Scheduling — Spring docs
🎉 Lesson Complete!
You now know how to offload work with @EnableAsync and @Async (returning CompletableFuture), configure a ThreadPoolTaskExecutor, schedule jobs with @EnableScheduling and @Scheduled (fixedRate/fixedDelay/cron), handle async exceptions, and avoid the self-invocation pitfall.
That completes the Spring & Enterprise track — you now have the tools to build, document, migrate and scale a production Spring Boot service.
Practice quiz
Which annotation turns on Spring's asynchronous method support?
- @EnableAsync
- @Async
- @Scheduled
- @EnableScheduling
Answer: @EnableAsync. @EnableAsync (on a @Configuration class) activates the proxy machinery that makes @Async methods run on a separate thread.
What does putting @Async on a method do?
- Schedules it on a timer
- Caches its result
- Runs it on a separate thread, returning to the caller immediately
- Makes it transactional
Answer: Runs it on a separate thread, returning to the caller immediately. @Async makes Spring invoke the method on a thread from a task executor, so the caller is not blocked while it runs.
What should an @Async method that returns a value return?
- An Optional<T>
- A CompletableFuture<T> (or Future<T>)
- void only
- A raw object
Answer: A CompletableFuture<T> (or Future<T>). To hand a result back to the caller, an @Async method returns CompletableFuture<T> (or the older Future), which the caller can wait on or compose.
Which executor type backs Spring's async tasks and is typically configured?
- ForkJoinPool only
- SingleThreadExecutor only
- ScheduledThreadPool
- ThreadPoolTaskExecutor
Answer: ThreadPoolTaskExecutor. ThreadPoolTaskExecutor is Spring's configurable thread pool; you define one as a bean to control pool size, queue capacity and thread naming.
Which annotation enables scheduled tasks?
- @EnableScheduling
- @EnableAsync
- @Scheduled
- @Timer
Answer: @EnableScheduling. @EnableScheduling activates support for @Scheduled methods; without it, scheduled methods are ignored.
What is the difference between fixedRate and fixedDelay on @Scheduled?
- They are identical
- fixedRate measures from each start; fixedDelay measures from each finish
- fixedDelay runs once only
- fixedRate uses cron syntax
Answer: fixedRate measures from each start; fixedDelay measures from each finish. fixedRate triggers every N ms from the previous start time, while fixedDelay waits N ms after the previous run finishes before starting again.
Which @Scheduled attribute uses cron expressions for complex schedules?
- initialDelay
- fixedRate
- fixedDelay
- cron
Answer: cron. The cron attribute accepts a cron expression (such as every weekday at 8am), giving calendar-style scheduling beyond simple intervals.
How do you handle an exception thrown by a void @Async method?
- A try/catch around the call site catches it
- It cannot throw
- With an AsyncUncaughtExceptionHandler
- Spring rethrows it to main()
Answer: With an AsyncUncaughtExceptionHandler. A void @Async method runs on another thread, so the caller cannot catch its exception; you provide an AsyncUncaughtExceptionHandler. A CompletableFuture-returning method carries the exception in the future.
Why does calling an @Async method from another method in the SAME class often run synchronously?
- A configuration bug
- Self-invocation bypasses the Spring proxy
- @Async is disabled by default
- The thread pool is empty
Answer: Self-invocation bypasses the Spring proxy. @Async works via a proxy. An internal self-call goes straight to the target object, skipping the proxy, so the async behaviour is bypassed.
Without @EnableAsync, what happens to a method annotated @Async?
- It runs synchronously, like a normal method
- It still runs asynchronously
- The application fails to start
- It is never called
Answer: It runs synchronously, like a normal method. If async support is not enabled, the @Async annotation is ignored and the method simply runs on the calling thread.
Continue this course
- Previous: API Docs with OpenAPI & springdoc
- Quick reference: Java cheat sheet
Frequently asked questions
How do @EnableAsync and @Async work together?
@EnableAsync, placed on a @Configuration class, switches on Spring's asynchronous execution support. It does this by wrapping your beans in proxies that intercept calls to methods annotated @Async. When such a method is called through the proxy, instead of running it on the caller's thread, Spring submits it to a task executor (a thread pool) and returns control to the caller immediately. So @EnableAsync is the master switch and @Async is the per-method marker. Crucially, if you forget @EnableAsync, the @Async annotation is silently ignored and the method just runs synchronously like any other, which is a common source of confusion.
When should an @Async method return CompletableFuture instead of void?
Return void when the work is fire-and-forget and the caller never needs the result, such as sending a notification email. Return CompletableFuture<T> (or the older Future<T>) when the caller does need the result or wants to know when the work completes. The future lets the caller continue immediately, then later call get() to wait, or compose further steps with thenApply and thenCompose, or combine several futures. The future also carries any exception thrown by the async work, so the caller can handle failure. With a void async method there is no return channel, so exceptions cannot reach the caller and must be handled by an AsyncUncaughtExceptionHandler instead.
What is the difference between fixedRate, fixedDelay and cron on @Scheduled?
All three need @EnableScheduling to be active. fixedRate runs the method every N milliseconds measured from the start of each execution, so runs can overlap if one takes longer than the interval. fixedDelay waits N milliseconds after the previous execution finishes before starting the next, so runs never overlap and the gap is between a finish and the next start. cron accepts a cron expression (seconds, minutes, hours, day-of-month, month, day-of-week) for calendar-style scheduling, like every weekday at 8am, which fixed intervals cannot express. There is also initialDelay to postpone the first fixedRate or fixedDelay run. Choose fixedDelay for jobs that must not overlap, fixedRate for steady cadence, and cron for time-of-day schedules.
How do I handle exceptions in asynchronous methods?
It depends on the return type. If the @Async method returns a CompletableFuture, any exception it throws is captured in the future; the caller sees it when calling get() (wrapped in an ExecutionException) or via exceptionally and handle in the composition chain. If the method returns void, the exception happens on another thread with nowhere to propagate, so a try/catch at the call site cannot catch it. For that case you register an AsyncUncaughtExceptionHandler, typically by implementing AsyncConfigurer and overriding getAsyncUncaughtExceptionHandler, so failures are logged or handled centrally rather than lost. The takeaway: prefer returning a future when you care about the outcome, and provide a handler for the fire-and-forget void cases.
Why does calling an @Async method from the same class run synchronously?
Because Spring's @Async works through a proxy. When another bean calls your async method, the call goes through the proxy, which intercepts it and dispatches the work to the thread pool. But when a method in the same class calls the async method directly, that internal call goes straight to the underlying object and never passes through the proxy, so the interception, and therefore the asynchronous behaviour, is bypassed and the method runs on the current thread. The same self-invocation problem affects @Transactional and @Cacheable for the same reason. The fix is to invoke the async method from a different bean, or to inject the bean into itself so the call goes through the proxy. This is the single most common @Async pitfall.