Back to blog

Spring Boot Async Processing & Scheduled Tasks

javaspring-bootasyncconcurrencybackend
Spring Boot Async Processing & Scheduled Tasks

Introduction

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

Prerequisites:


Why Async Processing?

Consider a typical user registration flow:

Synchronous (slow):
  Request → Save User → Send Email → Send SMS → Log Audit → Response
  Total: 50ms + 500ms + 300ms + 100ms = 950ms
 
Asynchronous (fast):
  Request → Save User → Response (50ms)
             ├── Send Email (background)
             ├── Send SMS (background)
             └── Log Audit (background)
  Total: ~50ms (user sees response immediately)

Async processing improves:

  • Response time — Users get immediate feedback
  • Throughput — Server threads aren't blocked waiting for slow I/O
  • Resilience — Background tasks can retry independently on failure
  • Scalability — Dedicated thread pools isolate workloads

Project Setup

Step 1: Add Dependencies

We'll use a standard Spring Boot web project. Add these to your pom.xml:

<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
 
    <!-- Spring Boot Starter Mail (for email examples) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-mail</artifactId>
    </dependency>
 
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
 
    <!-- Spring Boot Starter Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Step 2: Configure Application Properties

Update application.yml:

spring:
  application:
    name: async-demo
 
  # Mail configuration (for notification examples)
  mail:
    host: smtp.gmail.com
    port: 587
    username: ${MAIL_USERNAME:test@example.com}
    password: ${MAIL_PASSWORD:password}
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true
 
logging:
  level:
    com.example.async: DEBUG
    org.springframework.scheduling: DEBUG

Enabling Async Processing

Step 1: Enable @Async Support

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
@EnableAsync
public 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
@Slf4j
public 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
@Slf4j
public 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
        ));
    }
}

Test it:

curl -X POST http://localhost:8080/api/users/register \
  -H "Content-Type: application/json" \
  -d '{"name":"John","email":"john@example.com","phone":"+1234567890"}'

Expected log output:

Registering user: John on thread: http-nio-8080-exec-1
Registration complete for John (notifications sent in background)
Sending email to john@example.com on thread: task-1
Sending SMS to +1234567890 on thread: task-2
Email sent successfully to john@example.com
SMS 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
@Slf4j
public 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
@Slf4j
public 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)
        ));
    }
}

Performance comparison:

Sequential:     1000ms + 1500ms + 2000ms = 4500ms
Parallel async: max(1000, 1500, 2000)    = ~2000ms (2.25x faster)

Chaining CompletableFuture Operations

@GetMapping("/{userId}/summary")
public CompletableFuture<ResponseEntity<Map<String, String>>> getUserSummary(@PathVariable Long userId) {
    return dataService.fetchUserProfile(userId)
        .thenCombine(dataService.fetchUserOrders(userId), (profile, orders) -> {
            // Combine profile + orders
            return Map.of("profile", profile, "orders", orders);
        })
        .thenApply(result -> {
            // Transform the combined result
            return ResponseEntity.ok(result);
        })
        .exceptionally(ex -> {
            // Handle any errors
            return ResponseEntity.internalServerError()
                .body(Map.of("error", ex.getMessage()));
        });
}

Custom Thread Pool Configuration

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
@Slf4j
public 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
@EnableAsync
public 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 TypeCore SizeMax SizeQueueExample
CPU-bound# of CPUs# of CPUsSmall (10-50)Data processing
I/O-bound# of CPUs × 2# of CPUs × 4Large (100-500)HTTP calls, email
Mixed# of CPUs# of CPUs × 2Medium (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
@EnableScheduling
public 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
@Slf4j
public 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)
│ │ │ │ │ │
* * * * * *

Common patterns:

ExpressionDescription
0 0 * * * *Every hour
0 */15 * * * *Every 15 minutes
0 0 9 * * MON-FRIWeekdays at 9 AM
0 0 0 * * *Every midnight
0 0 0 1 * *First day of every month
0 0 9 * * MONEvery Monday at 9 AM

Externalize Cron Expressions

Keep schedules configurable via properties:

# application.yml
app:
  scheduling:
    weekly-report: "0 0 9 * * MON"
    daily-cleanup: "0 0 0 * * *"
    health-check: "0 */15 9-17 * * MON-FRI"
@Scheduled(cron = "${app.scheduling.weekly-report}")
public void generateWeeklyReport() {
    // Cron expression loaded from configuration
}

Conditional Scheduling

Disable scheduling in test environments:

@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "app.scheduling.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig {
}
# application-test.yml
app:
  scheduling:
    enabled: false

Error Handling in Async Methods

Handling Errors in void @Async Methods

For @Async methods that return void, unhandled exceptions are logged but silently lost. Use a custom exception handler:

@Configuration
@EnableAsync
@Slf4j
public class AsyncConfig implements AsyncConfigurer {
 
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            log.error("Unhandled async exception in {}.{}(): {}",
                method.getDeclaringClass().getSimpleName(),
                method.getName(),
                ex.getMessage(),
                ex
            );
 
            // Optionally send alert, write to dead letter queue, etc.
            // alertService.sendAlert("Async failure: " + method.getName());
        };
    }
}

Handling Errors in CompletableFuture Methods

For methods returning CompletableFuture, handle errors at the call site:

@Async
public CompletableFuture<String> riskyOperation() {
    // If this throws, the future completes exceptionally
    String result = externalApi.call();
    return CompletableFuture.completedFuture(result);
}
 
// Caller handles errors:
dataService.riskyOperation()
    .thenAccept(result -> log.info("Success: {}", result))
    .exceptionally(ex -> {
        log.error("Operation failed: {}", ex.getMessage());
        return null;
    });

Retry Pattern for Async Operations

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
@Slf4j
public class ResilientNotificationService {
 
    private static final int MAX_RETRIES = 3;
    private static final long RETRY_DELAY_MS = 1000;
 
    @Async
    public CompletableFuture<Boolean> sendEmailWithRetry(String to, String subject, String body) {
        for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
            try {
                log.info("Attempt {} to send email to {}", attempt, to);
                sendEmailInternal(to, subject, body);
                return CompletableFuture.completedFuture(true);
            } catch (Exception e) {
                log.warn("Attempt {} failed for {}: {}", attempt, to, e.getMessage());
                if (attempt < MAX_RETRIES) {
                    try {
                        Thread.sleep(RETRY_DELAY_MS * attempt); // Exponential backoff
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        return CompletableFuture.completedFuture(false);
                    }
                }
            }
        }
        log.error("All {} attempts failed for {}", MAX_RETRIES, to);
        return CompletableFuture.completedFuture(false);
    }
 
    private void sendEmailInternal(String to, String subject, String body) {
        // Actual email sending logic
        // mailSender.send(message);
        if (Math.random() < 0.3) {
            throw new RuntimeException("SMTP connection failed");
        }
    }
}

Hands-On Project: Background Notification Service

Let's build a complete notification service that demonstrates async processing in action.

Domain Model

package com.example.async.model;
 
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
 
import java.time.LocalDateTime;
 
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Notification {
    private Long id;
    private String recipient;
    private String channel;    // EMAIL, SMS, PUSH
    private String subject;
    private String message;
    private String status;     // PENDING, SENT, FAILED
    private LocalDateTime createdAt;
    private LocalDateTime sentAt;
    private int retryCount;
}

Notification Service

package com.example.async.service;
 
import com.example.async.model.Notification;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
 
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
 
@Service
@RequiredArgsConstructor
@Slf4j
public class BackgroundNotificationService {
 
    private final AtomicLong idGenerator = new AtomicLong(0);
    private final Map<Long, Notification> notificationStore = new ConcurrentHashMap<>();
 
    /**
     * Queue a notification for async delivery
     */
    public Notification queueNotification(String recipient, String channel, String subject, String message) {
        Notification notification = Notification.builder()
            .id(idGenerator.incrementAndGet())
            .recipient(recipient)
            .channel(channel)
            .subject(subject)
            .message(message)
            .status("PENDING")
            .createdAt(LocalDateTime.now())
            .retryCount(0)
            .build();
 
        notificationStore.put(notification.getId(), notification);
        log.info("Queued notification #{} ({}) to {}", notification.getId(), channel, recipient);
 
        // Process asynchronously
        processNotificationAsync(notification);
        return notification;
    }
 
    @Async("notificationExecutor")
    public void processNotificationAsync(Notification notification) {
        try {
            log.info("Processing notification #{} on thread: {}",
                notification.getId(), Thread.currentThread().getName());
 
            switch (notification.getChannel()) {
                case "EMAIL" -> sendEmail(notification);
                case "SMS" -> sendSms(notification);
                case "PUSH" -> sendPush(notification);
                default -> throw new IllegalArgumentException("Unknown channel: " + notification.getChannel());
            }
 
            notification.setStatus("SENT");
            notification.setSentAt(LocalDateTime.now());
            log.info("Notification #{} sent successfully", notification.getId());
 
        } catch (Exception e) {
            notification.setRetryCount(notification.getRetryCount() + 1);
            if (notification.getRetryCount() >= 3) {
                notification.setStatus("FAILED");
                log.error("Notification #{} permanently failed after {} attempts",
                    notification.getId(), notification.getRetryCount());
            } else {
                notification.setStatus("PENDING");
                log.warn("Notification #{} failed (attempt {}), will retry",
                    notification.getId(), notification.getRetryCount());
            }
        }
    }
 
    /**
     * Retry failed notifications every 5 minutes
     */
    @Scheduled(fixedRate = 300000)
    public void retryFailedNotifications() {
        List<Notification> pending = notificationStore.values().stream()
            .filter(n -> "PENDING".equals(n.getStatus()) && n.getRetryCount() > 0)
            .toList();
 
        if (!pending.isEmpty()) {
            log.info("Retrying {} failed notifications", pending.size());
            pending.forEach(this::processNotificationAsync);
        }
    }
 
    /**
     * Generate daily notification report at midnight
     */
    @Scheduled(cron = "0 0 0 * * *")
    public void generateDailyReport() {
        long total = notificationStore.size();
        long sent = notificationStore.values().stream()
            .filter(n -> "SENT".equals(n.getStatus())).count();
        long failed = notificationStore.values().stream()
            .filter(n -> "FAILED".equals(n.getStatus())).count();
        long pending = notificationStore.values().stream()
            .filter(n -> "PENDING".equals(n.getStatus())).count();
 
        log.info("Daily Notification Report - Total: {}, Sent: {}, Failed: {}, Pending: {}",
            total, sent, failed, pending);
    }
 
    public Map<String, Long> getStats() {
        long sent = notificationStore.values().stream()
            .filter(n -> "SENT".equals(n.getStatus())).count();
        long failed = notificationStore.values().stream()
            .filter(n -> "FAILED".equals(n.getStatus())).count();
        long pending = notificationStore.values().stream()
            .filter(n -> "PENDING".equals(n.getStatus())).count();
 
        return Map.of("total", (long) notificationStore.size(),
            "sent", sent, "failed", failed, "pending", pending);
    }
 
    public Notification getNotification(Long id) {
        return notificationStore.get(id);
    }
 
    private void sendEmail(Notification notification) throws InterruptedException {
        Thread.sleep(1500); // Simulate SMTP call
    }
 
    private void sendSms(Notification notification) throws InterruptedException {
        Thread.sleep(800); // Simulate SMS API
    }
 
    private void sendPush(Notification notification) throws InterruptedException {
        Thread.sleep(300); // Simulate push notification
    }
}

Notification Controller

package com.example.async.controller;
 
import com.example.async.model.Notification;
import com.example.async.service.BackgroundNotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
import java.util.Map;
 
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
 
    private final BackgroundNotificationService notificationService;
 
    @PostMapping
    public ResponseEntity<Notification> sendNotification(@RequestBody Map<String, String> request) {
        Notification notification = notificationService.queueNotification(
            request.get("recipient"),
            request.get("channel"),
            request.get("subject"),
            request.get("message")
        );
        return ResponseEntity.accepted().body(notification);
    }
 
    @GetMapping("/{id}")
    public ResponseEntity<Notification> getNotification(@PathVariable Long id) {
        Notification notification = notificationService.getNotification(id);
        if (notification == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(notification);
    }
 
    @GetMapping("/stats")
    public ResponseEntity<Map<String, Long>> getStats() {
        return ResponseEntity.ok(notificationService.getStats());
    }
}

Testing the Notification Service

# Send email notification
curl -X POST http://localhost:8080/api/notifications \
  -H "Content-Type: application/json" \
  -d '{"recipient":"user@example.com","channel":"EMAIL","subject":"Hello","message":"Welcome!"}'
 
# Send SMS notification
curl -X POST http://localhost:8080/api/notifications \
  -H "Content-Type: application/json" \
  -d '{"recipient":"+1234567890","channel":"SMS","subject":"Alert","message":"Your order shipped"}'
 
# Check notification status
curl http://localhost:8080/api/notifications/1
 
# View stats
curl http://localhost:8080/api/notifications/stats

Testing Async Code

Testing async methods requires special handling since assertions may run before the async task completes.

Unit Testing @Async Methods

package com.example.async.service;
 
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
 
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
 
import static org.assertj.core.api.Assertions.assertThat;
 
@SpringBootTest
class DataAggregationServiceTest {
 
    @Autowired
    private DataAggregationService dataService;
 
    @Test
    void shouldFetchUserProfileAsync() throws Exception {
        // When
        CompletableFuture<String> future = dataService.fetchUserProfile(1L);
 
        // Then - wait for async result
        String result = future.get(5, TimeUnit.SECONDS);
        assertThat(result).contains("User-1");
    }
 
    @Test
    void shouldRunTasksInParallel() throws Exception {
        long start = System.currentTimeMillis();
 
        CompletableFuture<String> profile = dataService.fetchUserProfile(1L);
        CompletableFuture<String> orders = dataService.fetchUserOrders(1L);
        CompletableFuture<String> recs = dataService.fetchUserRecommendations(1L);
 
        CompletableFuture.allOf(profile, orders, recs).get(10, TimeUnit.SECONDS);
 
        long duration = System.currentTimeMillis() - start;
 
        // Should run in parallel (~2000ms), not sequential (~4500ms)
        assertThat(duration).isLessThan(3500);
 
        assertThat(profile.get()).contains("User-1");
        assertThat(orders.get()).contains("5 orders");
        assertThat(recs.get()).contains("recommended");
    }
}

Testing Scheduled Tasks

package com.example.async.service;
 
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.awaitility.Awaitility;
 
import java.util.concurrent.TimeUnit;
 
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.verify;
 
@SpringBootTest(properties = {
    "app.scheduling.enabled=true"
})
class ScheduledTaskServiceTest {
 
    @SpyBean
    private ScheduledTaskService scheduledTaskService;
 
    @Test
    void shouldExecuteScheduledTask() {
        // Use Awaitility to wait for scheduled task
        Awaitility.await()
            .atMost(70, TimeUnit.SECONDS)
            .untilAsserted(() ->
                verify(scheduledTaskService, atLeast(1)).cleanExpiredSessions()
            );
    }
}

Add Awaitility to your test dependencies for easier async testing:

<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <scope>test</scope>
</dependency>

Production Best Practices

1. Always Configure Thread Pools

Never rely on the default SimpleAsyncTaskExecutor in production. It creates unbounded threads that can crash your application under load.

2. Name Your Threads

Thread name prefixes make debugging drastically easier:

executor.setThreadNamePrefix("email-");  // Shows as email-1, email-2, etc.

3. Handle Shutdown Gracefully

executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);

4. Monitor Thread Pool Metrics

Expose thread pool stats via Actuator:

@Bean
public ThreadPoolTaskExecutor taskExecutor(MeterRegistry registry) {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setThreadNamePrefix("async-");
    executor.initialize();
 
    // Register metrics
    registry.gauge("async.pool.size", executor, ThreadPoolTaskExecutor::getPoolSize);
    registry.gauge("async.pool.active", executor, ThreadPoolTaskExecutor::getActiveCount);
    registry.gauge("async.queue.size", executor, e -> e.getThreadPoolExecutor().getQueue().size());
 
    return executor;
}

5. Avoid Common Pitfalls

Calling @Async from the same class:

// BAD - @Async is bypassed (no proxy)
@Service
public class MyService {
    public void doWork() {
        asyncMethod(); // Runs synchronously!
    }
 
    @Async
    public void asyncMethod() { }
}
 
// GOOD - Call from a different bean
@Service
public 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:

// GOOD
catch (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:

  1. Always configure a proper ThreadPoolTaskExecutor for production
  2. Call @Async methods from a different bean (proxy limitation)
  3. Use CompletableFuture when you need results from async operations
  4. Handle InterruptedException correctly — restore the interrupt flag
  5. Externalize cron expressions via properties for flexibility

Next Steps:

  1. Practice: Add async email notifications to your existing projects
  2. Explore: Look into Spring Events (@EventListener) for decoupled async processing
  3. Scale up: Learn message queues (RabbitMQ, Kafka) for distributed async processing
  4. 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.


Additional Resources

Documentation:

Related Tutorials:


Happy coding! If you found this tutorial helpful, check out the complete Spring Boot Learning Roadmap for more topics.

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.