Published on
· Updated

Spring Boot Async Events: @EventListener, @TransactionalEventListener, and @Async

Authors

Spring Boot async events are useful when one application module needs to react to another without adding a direct service dependency. They are also easy to misuse. Adding @Async does not make an event durable, and adding @TransactionalEventListener does not automatically start a new transaction.

This guide separates four concerns that are often mixed together:

  1. Code coupling: should the publisher know the listener?
  2. Transaction timing: should the listener run before or after commit?
  3. Threading: should the publisher wait for the listener?
  4. Delivery guarantees: may the work be lost if the process crashes?

Version note

The behavior in this article was checked against Spring Boot 4.1.0 and Spring Framework 7.0.8 on August 10, 2026.

TL;DR

  • @EventListener is synchronous by default and normally runs on the publisher's thread.
  • @TransactionalEventListener defaults to AFTER_COMMIT; without a transaction, it does not run unless fallbackExecution=true.
  • Add @Async only when the listener may execute on another thread, and give that workload a bounded, named executor.
  • Use a new transaction for database writes performed by an after-commit listener.
  • An in-memory event can disappear after commit and before the listener finishes. Use Spring Modulith's event publication registry or a transactional outbox when delivery must survive a crash.

Choose the listener by required guarantee

The annotations are not progressively “better” versions of one another. They express different contracts.

Listener stylePublisher waitsTransaction relationshipSurvives process crashGood fit
@EventListenerYesCan participate in the publisher transactionNoRequired in-process invariant
@TransactionalEventListenerYes by defaultBound to a transaction phaseNoShort work that must happen only after commit
@Async + @TransactionalEventListenerNo after task submissionRuns after commit on another threadNoBest-effort local side effect
Spring Modulith event registryDepends on configurationPublication recorded with business transactionYes, when backed by a durable storeReliable module integration
Transactional outboxNoOutbox row and business change commit togetherYesKafka, RabbitMQ, email gateway, or another process

If inventory must be decremented atomically with an order, a synchronous call or synchronous listener can be correct. If an analytics hint may occasionally be missed, an async in-memory listener can be sufficient. If a paid order must eventually generate an external message, use a durable design.

Publish an immutable event inside the business transaction

Publish facts, not live JPA entities. An entity can be detached by the time an asynchronous listener reads it, and mutable state can change after publication. A compact event with stable identifiers and values is safer.

public record OrderPlaced(
    UUID eventId,
    UUID orderId,
    UUID customerId,
    Instant occurredAt
) {}

Publish the event from the same service method that performs the state change:

@Service
public class OrderService {

    private final OrderRepository orders;
    private final ApplicationEventPublisher events;

    public OrderService(OrderRepository orders, ApplicationEventPublisher events) {
        this.orders = orders;
        this.events = events;
    }

    @Transactional
    public UUID placeOrder(PlaceOrder command) {
        Order order = orders.save(Order.place(command));

        events.publishEvent(new OrderPlaced(
            UUID.randomUUID(),
            order.getId(),
            order.getCustomerId(),
            Instant.now()
        ));

        return order.getId();
    }
}

Calling publishEvent is a handoff to Spring's application context, not a message-broker send. With the default multicaster, listeners run synchronously. The event also has no built-in persistent record.

Understand normal @EventListener behavior

A regular listener is called on the publishing thread by default:

@Component
public class OrderPolicyListener {

    @EventListener
    public void validate(OrderPlaced event) {
        // An exception can propagate back to the publisher.
    }
}

That has useful properties. The listener can see the publisher's thread-bound transaction, and an exception can prevent the business transaction from committing. It also means slow network calls extend transaction time and request latency.

Use this style only when the listener is genuinely part of the synchronous consistency boundary. Sending email, calling an analytics API, or publishing to a remote broker usually should not hold database locks open.

Avoid making the application-wide ApplicationEventMulticaster asynchronous as a shortcut. It changes the behavior of every compatible listener and does not automatically propagate thread-local transaction or logging context. Per-listener @Async makes the boundary explicit.

Run only after a successful commit

@TransactionalEventListener binds delivery to a transaction phase:

@Component
public class OrderAuditListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced event) {
        // The order transaction has committed successfully.
    }
}

The available phases are:

  • BEFORE_COMMIT
  • AFTER_COMMIT, the default
  • AFTER_ROLLBACK
  • AFTER_COMPLETION, for either outcome

There are two important edge cases.

First, a transactional listener is not called when no transaction is active. fallbackExecution=true changes that behavior, but it also creates two possible semantics for the same listener. In most business flows it is clearer to require a transaction and test that requirement.

Second, AFTER_COMMIT does not mean “start a fresh transaction after commit.” Spring's API documentation warns that transaction resources can still be accessible even though the transaction has completed; writes performed through those resources are not committed.

If the listener must write to the database, start a separate transaction in another Spring bean:

@Service
public class NotificationRequestService {

    private final NotificationRequestRepository requests;

    public NotificationRequestService(NotificationRequestRepository requests) {
        this.requests = requests;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createFor(OrderPlaced event) {
        requests.save(NotificationRequest.forOrder(
            event.eventId(),
            event.orderId(),
            event.customerId()
        ));
    }
}

REQUIRES_NEW needs another database connection while the outer transaction's resources may still be held. Size the connection pool for that possibility and do not apply the propagation setting casually to deeply nested flows.

Add @Async for a separate execution thread

An after-commit listener is still synchronous unless it is explicitly made asynchronous. Enable async method interception and select a named executor:

@Configuration
@EnableAsync
public class DomainEventAsyncConfiguration {

    @Bean(name = "domainEventExecutor")
    public TaskExecutor domainEventExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(12);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("domain-event-");
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}

Then apply both annotations to the listener:

@Component
public class OrderNotificationListener {

    private final NotificationRequestService requests;

    public OrderNotificationListener(NotificationRequestService requests) {
        this.requests = requests;
    }

    @Async("domainEventExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced event) {
        requests.createFor(event);
    }
}

The request path now submits the listener after the order commit and can return without waiting for the database write in createFor.

The executor limits are part of the reliability design:

  • a finite queue prevents unlimited heap growth;
  • a finite maximum bounds concurrent downstream pressure;
  • a visible rejection policy exposes overload instead of silently discarding work;
  • graceful shutdown gives accepted tasks time to finish, but it cannot make them durable.

Choose numbers from measured service time and downstream capacity. Twelve listener threads are useless if the database pool has only four available connections. For a deeper explanation of queue behavior, see Spring Boot thread pool configuration.

Handle async failures deliberately

An exception from a void @Async method cannot travel back to the HTTP caller. Spring logs it by default. An AsyncUncaughtExceptionHandler can add structured logging and metrics:

@Configuration
@EnableAsync
public class AsyncFailureConfiguration implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (failure, method, arguments) -> {
            // Record the listener name, exception class, and a safe event ID.
            // Do not log an entire payload containing personal data.
        };
    }
}

This handler observes failure; it does not recover the event. A retry annotation can help with a short transient failure, but retry state exists only in the process. The event is still lost if the JVM exits after the order commits and before the listener completes.

For best-effort work, record at least:

  • submitted, active, queued, completed, rejected, and failed task counts;
  • listener duration;
  • event type and stable event ID;
  • shutdown timeouts;
  • age of any persisted retry or recovery record.

Do not copy unrestricted event payloads into metric labels or logs. IDs have high cardinality and belong in trace attributes or structured logs, not Prometheus labels.

Know the crash window

The critical sequence is:

  1. PostgreSQL commits the order.
  2. Spring invokes the after-commit listener.
  3. @Async submits a task.
  4. The task performs its side effect.

A crash between any of the last three steps can leave the order committed without the side effect. Graceful shutdown covers only planned termination and only tasks already accepted by the executor.

Use one of these durable alternatives when that gap is unacceptable:

Spring Modulith event publication registry

Spring Modulith can record transactional event publications in the original transaction and mark them complete after listeners succeed. Incomplete or failed publications can be found and resubmitted. This is a strong fit for reliable communication between modules in one Spring Boot application.

Transactional outbox

Insert an outbox row with the business change, then let a separate relay publish it to Kafka or another system. Both the relay and downstream consumers must tolerate duplicates. Read the transactional outbox implementation guide when the event crosses a process boundary.

@Async plus @TransactionalEventListener is therefore a latency and coupling tool, not a replacement for a queue.

Test transaction outcome and execution context

An event test should verify more than “the listener method was called.” Cover these cases:

  1. A committed publisher transaction triggers the listener.
  2. A rolled-back publisher transaction does not trigger an AFTER_COMMIT listener.
  3. A call without a transaction does not trigger it when fallback execution is disabled.
  4. The listener runs on the named executor rather than the request thread.
  5. A database write in the listener commits in its new transaction.
  6. Queue saturation produces the expected rejection signal.
  7. Application shutdown either completes accepted tasks within the deadline or reports unfinished work.

Use a real database for transaction-bound integration tests. A mocked repository cannot prove commit or rollback behavior.

Common mistakes

Treating async as non-blocking I/O

@Async moves blocking work to another thread. It does not turn a blocking JDBC or HTTP call into non-blocking I/O.

Passing a JPA entity in the event

The asynchronous listener can see a detached entity, uninitialized lazy relationship, or state that no longer matches the published fact. Pass identifiers and immutable values.

Writing after commit without a new transaction

The listener can appear to call save successfully while no new commit occurs. Put the write behind a separate proxied service with an explicit transaction.

Assuming retries close the durability gap

In-process retries cannot recover work that was never submitted or was interrupted by a crash. Persist the publication when delivery matters.

Ignoring proxy boundaries

Spring's default @Async support is proxy based. Calls made directly from one method to another method on the same instance bypass the proxy. Keep asynchronous entry points on a separate Spring bean.

Production decision checklist

  • Does the listener need to share the publisher transaction?
  • Must it run only after a successful commit?
  • Is increased response latency acceptable?
  • Can the work be lost during a process crash?
  • Is the event immutable and free of live entity references?
  • Is the executor named, bounded, observable, and sized against downstream capacity?
  • Does listener database work start a real transaction?
  • Are failures recoverable, or merely logged?
  • Have rollback, overload, restart, and shutdown paths been tested?

For optional, local work, a bounded async transactional listener is a clean solution. For required work, persist the intent first and process it with an idempotent, recoverable mechanism.

Official references