Stop Misusing @Async: 4 Advanced Async Rules Every Senior Developer Should Follow

The article explains why careless use of Spring Boot’s @Async can cause thread‑pool exhaustion, silent failures, and transaction inconsistencies, and presents four advanced patterns—custom thread pools, CompletableFuture parallelism, @TransactionalEventListener, and AsyncUncaughtExceptionHandler—to use @Async safely and observably.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Stop Misusing @Async: 4 Advanced Async Rules Every Senior Developer Should Follow

When a Spring Boot project blindly annotates dozens of methods with @Async, the initial speed boost can quickly turn into thread‑pool saturation, silent task queuing, and lost orders, with no exceptions or alerts logged. The root cause is that @Async defaults to an implicitly created SimpleAsyncTaskExecutor if no explicit executor bean is provided.

Default @Async Executor

Without any configuration, Spring looks for a bean named applicationTaskExecutor of type Executor. Spring Boot supplies one, but its core parameters are unsuitable for production workloads, as illustrated in the diagram below.

Default executor diagram
Default executor diagram

Mode 1 – Configure Your Own Thread Pool (Never Use the Default)

Using the default executor creates a new thread for every call, which can exhaust memory under high load. The correct approach is to define named ThreadPoolTaskExecutor beans with clear boundaries.

// ✅ Correct – define named thread pools
@Configuration
@EnableAsync
public class AsyncConfig {

    // Email task pool – bounded, observable, named
    @Bean("emailExecutor")
    Executor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);          // always‑alive threads
        executor.setMaxPoolSize(10);         // burst limit
        executor.setQueueCapacity(100);      // queue before rejection
        executor.setThreadNamePrefix("email-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }

    // Inventory task pool – different SLA
    @Bean("inventoryExecutor")
    Executor inventoryExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("inventory-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        return executor;
    }
}

// Explicitly bind each @Async method to a pool
@Service
public class EmailService {
    @Async("emailExecutor")
    public CompletableFuture<Void> sendWelcomeEmail(String userEmail) {
        emailGateway.send(userEmail);
        return CompletableFuture.completedFuture(null);
    }
}

Now you can see the exact thread count, thread‑dump names, and chosen rejection policy. CallerRunsPolicy provides gentle back‑pressure, while AbortPolicy fails fast for non‑droppable tasks.

Mode 2 – CompletableFuture for Parallel Tasks that Must All Complete

Returning void from an @Async method discards results. When multiple independent calls need to finish before responding, serial execution wastes time.

// ❌ Incorrect – serial execution
@GetMapping("/dashboard/{userId}")
public DashboardDTO getDashboard(@PathVariable Long userId) {
    // total time = 200ms + 150ms + 300ms = 650ms
    UserProfile profile = ps.getProfile(userId);          // 200ms
    List<Order> orders = os.getRecentOrders(userId);      // 150ms
    AccountBalance balance = bs.getBalance(userId);      // 300ms
    return new DashboardDTO(profile, orders, balance);
}

The proper solution launches the three calls concurrently with CompletableFuture and waits for the slowest one.

// ✅ Correct – parallel with CompletableFuture
@Service
public class DashboardService {
    private final ProfileService profileService;
    private final OrderService orderService;
    private final BillingService billingService;

    public DashboardDTO buildDashboard(Long userId) throws ExecutionException, InterruptedException {
        CompletableFuture<UserProfile> profileFuture = CompletableFuture.supplyAsync(
            () -> profileService.getProfile(userId), dashboardExecutor);
        CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(
            () -> orderService.getRecentOrders(userId), dashboardExecutor);
        CompletableFuture<AccountBalance> balanceFuture = CompletableFuture.supplyAsync(
            () -> billingService.getBalance(userId), dashboardExecutor);
        return CompletableFuture.allOf(profileFuture, ordersFuture, balanceFuture)
            .thenApply(v -> new DashboardDTO(
                profileFuture.join(),
                ordersFuture.join(),
                balanceFuture.join()))
            .exceptionally(ex -> {
                logger.error("Dashboard assembly failed for user {}", userId, ex);
                return DashboardDTO.partial(); // graceful degradation
            })
            .get();
    }
}

The overall latency becomes the longest individual call (e.g., 300 ms) instead of the sum, and errors are handled explicitly.

Mode 3 – @TransactionalEventListener for Post‑Commit Side Effects

Triggering an asynchronous method before a transaction commits can send emails or update inventory for a rolled‑back order. The safe pattern is to publish an event inside the transaction and listen with @TransactionalEventListener at the appropriate phase.

// ❌ Incorrect – async side‑effect ignores transaction state
@Service
public class OrderService {
    @Transactional
    public Order createOrder(OrderRequest request) {
        Order order = orderRepository.save(request.toOrder());
        eventPublisher.publishEvent(new OrderCreatedEvent(this, order)); // fires before commit
        return order;
    }
}

@Component
public class OrderEmailListener {
    @Async
    @EventListener // runs before commit – wrong
    public void onOrderCreated(OrderCreatedEvent event) {
        emailService.sendConfirmation(event.getOrder());
    }
}

Correct usage binds the async listener to the AFTER_COMMIT phase (or other phases as needed), ensuring the side effect only runs when the transaction succeeds.

// ✅ Correct – execute after transaction commit
@Component
public class OrderEmailListener {
    @Async("emailExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        emailService.sendConfirmation(event.getOrder());
    }
}

@Component
public class InventoryListener {
    @Async("inventoryExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        inventoryService.reserveItems(event.getOrder());
    }
}

@Component
public class LoyaltyListener {
    @Async("loyaltyExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void onOrderFailed(OrderCreatedEvent event) {
        loyaltyService.releaseHeldPoints(event.getOrder().getUserId());
    }
}
@TransactionalEventListener

supports four phases (AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION, BEFORE_COMMIT), letting you decouple business logic from side‑effects while keeping the codebase extensible.

Mode 4 – Visible Error Handling for @Async

A

void
@Async

method that throws an exception silently discards the error, leaving no logs or alerts.

// ❌ Incorrect – exception disappears
@Async
public void syncUserToSearchIndex(Long userId) {
    User user = userRepository.findById(userId).orElseThrow();
    searchClient.index(user); // may throw timeout
    // exception is swallowed, index never updated, no trace
}

The robust solution registers an AsyncUncaughtExceptionHandler to log and notify on such failures, and prefers returning a CompletableFuture so callers can handle success or failure explicitly.

// ✅ Correct – structured error handling
@Configuration
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            logger.error("Async method '{}' failed with params {} - {}",
                method.getName(), Arrays.toString(params), throwable.getMessage(), throwable);
            alertingService.notifyAsync(method.getName(), throwable);
        };
    }
}

@Async("searchExecutor")
public CompletableFuture<Void> syncUserToSearchIndex(Long userId) {
    return CompletableFuture.runAsync(() -> {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new NotFoundException("User", userId));
        try {
            searchClient.index(user);
        } catch (SearchClientException e) {
            logger.error("Search index sync failed for userId={}", userId, e);
            throw e;
        }
    }, searchExecutor);
}

public void onUserUpdated(Long userId) {
    syncUserToSearchIndex(userId)
        .whenComplete((result, ex) -> {
            if (ex != null) {
                retryQueue.enqueue(new SearchSyncTask(userId));
            }
        });
}

With this handler, every silent failure becomes a visible log entry, and callers can react (retry, dead‑letter, degrade) instead of waiting indefinitely.

Conclusion

Misusing @Async hides concurrency problems; senior developers should make the asynchronous behavior explicit by configuring dedicated executors, using CompletableFuture for parallelism, coupling side‑effects to transaction boundaries with @TransactionalEventListener, and installing proper error handling via AsyncUncaughtExceptionHandler or returned futures.

Illustration
Illustration
Diagram
Diagram
Diagram
Diagram
Diagram
Diagram
Diagram
Diagram
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

ThreadPoolCompletableFuturespring-bootAsyncTransactionalEventListenerAsyncUncaughtExceptionHandler
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.