In real-world applications, not every operation needs to happen synchronously. Sending emails, generating reports, processing file uploads, and syncing data with external systems are all tasks that can — and should — run in the background.
Spring Boot provides powerful built-in support for asynchronous processing through the @Async annotation and CompletableFuture, plus scheduled task execution with @Scheduled. Together, these tools let you build responsive, efficient applications that handle long-running operations without blocking user requests.
What You'll Learn:
✅ Enabling and configuring async processing with @Async
✅ Returning results from async methods with CompletableFuture
✅ Custom thread pool configuration for production
✅ Scheduled tasks with @Scheduled and cron expressions
✅ Error handling in async workflows
✅ Combining multiple async operations
✅ Building a background notification service
Create a configuration class to enable async processing:
package com.example.async.config;import org.springframework.context.annotation.Configuration;import org.springframework.scheduling.annotation.EnableAsync;@Configuration@EnableAsyncpublic class AsyncConfig { // @EnableAsync activates Spring's async execution capability // By default, it uses a SimpleAsyncTaskExecutor (no thread reuse) // We'll configure a proper thread pool next}
Step 2: Create a Basic Async Service
package com.example.async.service;import lombok.extern.slf4j.Slf4j;import org.springframework.scheduling.annotation.Async;import org.springframework.stereotype.Service;@Service@Slf4jpublic class NotificationService { @Async public void sendEmail(String to, String subject, String body) { log.info("Sending email to {} on thread: {}", to, Thread.currentThread().getName()); try { // Simulate slow email sending Thread.sleep(2000); log.info("Email sent successfully to {}", to); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("Email sending interrupted for {}", to); } } @Async public void sendSms(String phoneNumber, String message) { log.info("Sending SMS to {} on thread: {}", phoneNumber, Thread.currentThread().getName()); try { Thread.sleep(1000); log.info("SMS sent successfully to {}", phoneNumber); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("SMS sending interrupted for {}", phoneNumber); } }}
Step 3: Call Async Methods from Controller
package com.example.async.controller;import com.example.async.service.NotificationService;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.Map;@RestController@RequestMapping("/api/users")@RequiredArgsConstructor@Slf4jpublic class UserController { private final NotificationService notificationService; @PostMapping("/register") public ResponseEntity<Map<String, String>> registerUser(@RequestBody Map<String, String> user) { String email = user.get("email"); String phone = user.get("phone"); String name = user.get("name"); log.info("Registering user: {} on thread: {}", name, Thread.currentThread().getName()); // Save user to database (synchronous) // userRepository.save(user); // Send notifications (asynchronous - returns immediately) notificationService.sendEmail(email, "Welcome!", "Welcome to our platform, " + name); notificationService.sendSms(phone, "Your account has been created."); log.info("Registration complete for {} (notifications sent in background)", name); return ResponseEntity.ok(Map.of( "message", "User registered successfully", "email", email )); }}
Registering user: John on thread: http-nio-8080-exec-1Registration complete for John (notifications sent in background)Sending email to john@example.com on thread: task-1Sending SMS to +1234567890 on thread: task-2Email sent successfully to john@example.comSMS sent successfully to +1234567890
Notice how the response returns before the email and SMS are sent. The notifications run on different threads (task-1, task-2).
Important:@Async methods must be called from a different class. Calling an @Async method from within the same class bypasses the proxy and runs synchronously. This is because Spring uses AOP proxies to intercept the call and dispatch it to a separate thread.
Returning Results with CompletableFuture
@Async methods can return CompletableFuture<T> when you need the result later:
Async Service with Return Values
package com.example.async.service;import lombok.extern.slf4j.Slf4j;import org.springframework.scheduling.annotation.Async;import org.springframework.stereotype.Service;import java.util.concurrent.CompletableFuture;@Service@Slf4jpublic class DataAggregationService { @Async public CompletableFuture<String> fetchUserProfile(Long userId) { log.info("Fetching user profile for {} on thread: {}", userId, Thread.currentThread().getName()); try { Thread.sleep(1000); // Simulate external API call } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return CompletableFuture.completedFuture("User-" + userId + " profile data"); } @Async public CompletableFuture<String> fetchUserOrders(Long userId) { log.info("Fetching orders for {} on thread: {}", userId, Thread.currentThread().getName()); try { Thread.sleep(1500); // Simulate database query } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return CompletableFuture.completedFuture("User-" + userId + " has 5 orders"); } @Async public CompletableFuture<String> fetchUserRecommendations(Long userId) { log.info("Fetching recommendations for {} on thread: {}", userId, Thread.currentThread().getName()); try { Thread.sleep(2000); // Simulate ML service call } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return CompletableFuture.completedFuture("3 recommended products for User-" + userId); }}
Combining Multiple Async Results
package com.example.async.controller;import com.example.async.service.DataAggregationService;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.Map;import java.util.concurrent.CompletableFuture;@RestController@RequestMapping("/api/dashboard")@RequiredArgsConstructor@Slf4jpublic class DashboardController { private final DataAggregationService dataService; @GetMapping("/{userId}") public ResponseEntity<Map<String, String>> getUserDashboard(@PathVariable Long userId) throws Exception { long start = System.currentTimeMillis(); // Launch all three calls in parallel CompletableFuture<String> profileFuture = dataService.fetchUserProfile(userId); CompletableFuture<String> ordersFuture = dataService.fetchUserOrders(userId); CompletableFuture<String> recommendationsFuture = dataService.fetchUserRecommendations(userId); // Wait for all to complete CompletableFuture.allOf(profileFuture, ordersFuture, recommendationsFuture).join(); long duration = System.currentTimeMillis() - start; log.info("Dashboard loaded in {}ms (sequential would be ~4500ms)", duration); return ResponseEntity.ok(Map.of( "profile", profileFuture.get(), "orders", ordersFuture.get(), "recommendations", recommendationsFuture.get(), "loadTimeMs", String.valueOf(duration) )); }}
The default SimpleAsyncTaskExecutor creates a new thread for every task — this is not suitable for production. Configure a proper thread pool:
Production-Ready Thread Pool
package com.example.async.config;import lombok.extern.slf4j.Slf4j;import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.scheduling.annotation.AsyncConfigurer;import org.springframework.scheduling.annotation.EnableAsync;import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;import java.util.concurrent.Executor;@Configuration@EnableAsync@Slf4jpublic class AsyncConfig implements AsyncConfigurer { @Override @Bean(name = "taskExecutor") public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // Core pool size: threads kept alive even when idle executor.setCorePoolSize(5); // Max pool size: maximum threads when queue is full executor.setMaxPoolSize(10); // Queue capacity: tasks waiting when core threads are busy executor.setQueueCapacity(100); // Thread name prefix for easier debugging executor.setThreadNamePrefix("async-"); // What to do when pool and queue are full executor.setRejectedExecutionHandler((r, e) -> log.error("Task rejected: thread pool and queue are full") ); // Wait for tasks to complete on shutdown executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> log.error("Async error in method {}: {}", method.getName(), ex.getMessage(), ex); }}
Multiple Thread Pools for Different Workloads
Isolate different workloads with dedicated pools:
@Configuration@EnableAsyncpublic class AsyncConfig { @Bean(name = "emailExecutor") public Executor emailExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(5); executor.setQueueCapacity(50); executor.setThreadNamePrefix("email-"); executor.initialize(); return executor; } @Bean(name = "reportExecutor") public Executor reportExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(3); executor.setMaxPoolSize(8); executor.setQueueCapacity(25); executor.setThreadNamePrefix("report-"); executor.initialize(); return executor; } @Bean(name = "notificationExecutor") public Executor notificationExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(200); executor.setThreadNamePrefix("notify-"); executor.initialize(); return executor; }}
Reference a specific pool with the @Async value:
@Async("emailExecutor")public void sendEmail(String to, String subject, String body) { // Runs on the emailExecutor pool}@Async("reportExecutor")public CompletableFuture<byte[]> generateReport(Long reportId) { // Runs on the reportExecutor pool return CompletableFuture.completedFuture(new byte[0]);}
Thread Pool Sizing Guide
Workload Type
Core Size
Max Size
Queue
Example
CPU-bound
# of CPUs
# of CPUs
Small (10-50)
Data processing
I/O-bound
# of CPUs × 2
# of CPUs × 4
Large (100-500)
HTTP calls, email
Mixed
# of CPUs
# of CPUs × 2
Medium (50-100)
General async
Scheduled Tasks with @Scheduled
Spring's @Scheduled annotation lets you run methods at fixed intervals or cron schedules.
Step 1: Enable Scheduling
package com.example.async.config;import org.springframework.context.annotation.Configuration;import org.springframework.scheduling.annotation.EnableScheduling;@Configuration@EnableSchedulingpublic class SchedulingConfig {}
Step 2: Create Scheduled Tasks
package com.example.async.service;import lombok.extern.slf4j.Slf4j;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Service;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;@Service@Slf4jpublic class ScheduledTaskService { /** * Fixed rate: runs every 60 seconds regardless of previous execution time */ @Scheduled(fixedRate = 60000) public void cleanExpiredSessions() { log.info("Cleaning expired sessions at {}", now()); // sessionRepository.deleteExpired(); } /** * Fixed delay: waits 30 seconds AFTER previous execution completes */ @Scheduled(fixedDelay = 30000) public void syncExternalData() { log.info("Syncing external data at {}", now()); // externalApiClient.sync(); } /** * Initial delay: waits 10 seconds before first execution, * then runs every 5 minutes */ @Scheduled(fixedRate = 300000, initialDelay = 10000) public void refreshCache() { log.info("Refreshing cache at {}", now()); // cacheService.refreshAll(); } /** * Cron expression: runs at 9:00 AM every Monday * Format: second minute hour day-of-month month day-of-week */ @Scheduled(cron = "0 0 9 * * MON") public void generateWeeklyReport() { log.info("Generating weekly report at {}", now()); // reportService.generateWeekly(); } /** * Cron: runs every day at midnight */ @Scheduled(cron = "0 0 0 * * *") public void dailyCleanup() { log.info("Running daily cleanup at {}", now()); // cleanupService.runDaily(); } /** * Cron: runs every 15 minutes during business hours (Mon-Fri, 9AM-5PM) */ @Scheduled(cron = "0 */15 9-17 * * MON-FRI") public void checkHealthDuringBusinessHours() { log.info("Health check at {}", now()); // healthService.check(); } private String now() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); }}
Cron Expression Reference
┌───────────── second (0-59)│ ┌───────────── minute (0-59)│ │ ┌───────────── hour (0-23)│ │ │ ┌───────────── day of month (1-31)│ │ │ │ ┌───────────── month (1-12)│ │ │ │ │ ┌───────────── day of week (0-7, SUN-SAT)│ │ │ │ │ │* * * * * *
// BAD - @Async is bypassed (no proxy)@Servicepublic class MyService { public void doWork() { asyncMethod(); // Runs synchronously! } @Async public void asyncMethod() { }}// GOOD - Call from a different bean@Servicepublic class CallerService { private final AsyncService asyncService; public void doWork() { asyncService.asyncMethod(); // Runs asynchronously }}
Missing @EnableAsync:
// @Async does nothing without @EnableAsync on a @Configuration class
Not handling InterruptedException properly:
// GOODcatch (InterruptedException e) { Thread.currentThread().interrupt(); // Restore interrupt flag throw new RuntimeException("Task interrupted", e);}
Summary and Key Takeaways
You've learned how to build asynchronous, non-blocking applications with Spring Boot.
What We Covered:
✅ @Async for fire-and-forget background tasks
✅ CompletableFuture for async methods with return values
✅ Custom thread pool configuration for production
✅ Multiple thread pools for workload isolation
✅ @Scheduled for fixed-rate, fixed-delay, and cron tasks
✅ Error handling and retry patterns
✅ Building a background notification service
✅ Testing async and scheduled code
Key Rules:
Always configure a proper ThreadPoolTaskExecutor for production
Call @Async methods from a different bean (proxy limitation)
Use CompletableFuture when you need results from async operations
Handle InterruptedException correctly — restore the interrupt flag
Externalize cron expressions via properties for flexibility
Next Steps:
Practice: Add async email notifications to your existing projects
Explore: Look into Spring Events (@EventListener) for decoupled async processing
Monitor: Add thread pool metrics to your Actuator dashboards
Frequently Asked Questions
Q: What's the difference between fixedRate and fixedDelay?
A: fixedRate triggers at a constant interval regardless of execution time. If a task takes 3 seconds and the rate is 5 seconds, the next run starts 2 seconds after the previous one finishes. fixedDelay waits the specified time after the previous execution completes — so a 5-second delay always means 5 seconds of idle time between runs.
Q: Can I use @Async with @Transactional?
A: Yes, but note that the async method runs in its own thread with its own transaction context. The calling method's transaction will not propagate to the async method. If you need transactional behavior, annotate the async method itself with @Transactional.
Q: How many threads should I configure?
A: For I/O-bound tasks (HTTP calls, email, database), start with 2× CPU cores for core size and 4× for max. For CPU-bound tasks, match core size to CPU count. Monitor under load and adjust.
Q: What happens if the thread pool is full?
A: By default, tasks go into the queue. When the queue is also full, the RejectedExecutionHandler is invoked. Options include: AbortPolicy (throw exception), CallerRunsPolicy (run on calling thread), DiscardPolicy (silently drop), or a custom handler.
Q: Should I use @Async or a message queue like RabbitMQ?
A: Use @Async for simple in-process background tasks. Use a message queue when you need: task persistence across restarts, distributed processing across multiple servers, guaranteed delivery, or complex routing. Message queues add operational complexity but provide stronger guarantees.