Combining @Async and @Transactional in Spring Boot is a common anti-pattern that silently loses your transaction context. Learn why it breaks and how to fix it correctly.
JOptimize Team
Combining @Async and @Transactional on the same method in Spring Boot is one of those bugs that looks perfectly reasonable on the surface - and silently corrupts your data in production. The transaction context doesn't transfer to async threads, so your database writes either run without a transaction or fail in unexpected ways.
Spring's @Transactional binds the transaction to the current thread using a ThreadLocal. When @Async executes your method on a different thread from the pool, that thread has no transaction context - it's a clean slate.
// Looks fine. Actually broken. @Service public class OrderService { @Async @Transactional // ? Transaction is created, then immediately lost public void processOrderAsync(Long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); order.setStatus(OrderStatus.PROCESSING); inventoryService.reserveStock(order); // runs without transaction! notificationService.notify(order); // if this throws, nothing rolls back } }
What actually happens:
processOrderAsync() through the proxy@Async proxy dispatches the work to a thread pool thread@Transactional proxy on that new thread opens a new transactionEnable transaction logging to see what's happening:
logging.level.org.springframework.transaction=DEBUG logging.level.org.springframework.scheduling=DEBUG
You'll see the transaction opened on thread task-1 has no relation to what the caller expected:
[task-1] Creating new transaction with name [OrderService.processOrderAsync] [task-1] Committing JPA transaction on EntityManager
Also, CompletableFuture exceptions are silently swallowed unless you explicitly handle them:
// This exception disappears into the void CompletableFuture<Void> future = orderService.processOrderAsync(orderId); // No .exceptionally() or .get() = silent failure
The cleanest fix is to make the async method call a separate transactional service:
@Service @RequiredArgsConstructor public class OrderService { private final OrderProcessor orderProcessor; @Async public CompletableFuture<Void> processOrderAsync(Long orderId) { try { orderProcessor.processInTransaction(orderId); // ? Transaction here return CompletableFuture.completedFuture(null); } catch (Exception e) { return CompletableFuture.failedFuture(e); } } } @Service public class OrderProcessor { @Transactional // ? Runs on the async thread, but correctly scoped public void processInTransaction(Long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); order.setStatus(OrderStatus.PROCESSING); inventoryService.reserveStock(order); } }
The transaction is now cleanly owned by the async thread, with a clear rollback boundary.
For event-driven patterns, @TransactionalEventListener gives you async-safe transactional semantics:
@Service public class OrderService { @Autowired private ApplicationEventPublisher eventPublisher; @Transactional public void placeOrder(Order order) { orderRepository.save(order); // Event is published AFTER the transaction commits eventPublisher.publishEvent(new OrderPlacedEvent(order.getId())); } } @Component public class OrderEventHandler { // Runs after the outer transaction commits, in a new transaction @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) public void onOrderPlaced(OrderPlacedEvent event) { // Safe: outer transaction already committed notificationService.sendConfirmation(event.getOrderId()); inventoryService.reserveStock(event.getOrderId()); } }
This pattern guarantees the event handler only runs if the main transaction succeeded.
If you use @Async extensively, configure a named executor with proper error handling:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-order-"); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> { log.error("Async method {} failed with: {}", method.getName(), ex.getMessage(), ex); // Alert, retry, or dead-letter queue logic here }; } }
Without AsyncUncaughtExceptionHandler, exceptions in void async methods are silently dropped.
@Async + @Transactional on the same method - the transaction belongs to the async thread, but is disconnected from the caller; never assume they share a transactionCompletableFuture failures - always chain .exceptionally() or call .get() to surface errorscorePoolSize, maxPoolSize, and queueCapacity@Async and @Transactional on the same method is a subtle anti-pattern - the transaction context doesn't transfer across threads. The fix is to separate the async boundary from the transaction boundary: keep @Async on the outer method and delegate to a @Transactional service method. For event-driven flows, @TransactionalEventListener gives you the cleanest semantics.
JOptimize detects methods annotated with both @Async and @Transactional and flags them as data integrity risks, with a suggested refactoring pattern.
Catch async transaction bugs before they corrupt production data - free scan, no configuration required.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.