Back to Blog
spring-bootasynctransactionaljavaspringconcurrency

@Async and @Transactional in Spring Boot: The Anti-Pattern That Silently Corrupts Data (2026)

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.

J

JOptimize Team

May 20, 2026· 7 min read

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.


Why the Combination Breaks

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:

  1. The caller's thread calls processOrderAsync() through the proxy
  2. Spring's @Async proxy dispatches the work to a thread pool thread
  3. Spring's @Transactional proxy on that new thread opens a new transaction
  4. BUT - if the caller already had a transaction open, it's completely disconnected
  5. Exceptions in the async method don't roll back anything in the caller's context

Detecting the Problem

Enable 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

Solution 1: Separate the Transaction from the Async Boundary

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.


Solution 2: Use @TransactionalEventListener

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.


Solution 3: Configure the Async Executor Properly

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.


Common Mistakes to Avoid

  • @Async + @Transactional on the same method - the transaction belongs to the async thread, but is disconnected from the caller; never assume they share a transaction
  • Not handling CompletableFuture failures - always chain .exceptionally() or call .get() to surface errors
  • Assuming rollback propagates to the caller - an exception in an async method never rolls back the caller's transaction
  • Using the default Spring async executor in production - it's unbounded by default; always configure corePoolSize, maxPoolSize, and queueCapacity

Summary

@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.


Detect @Async/@Transactional Anti-Patterns in Your Codebase

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.