skip to content

How do @Async, exception handling, and durability interact with AFTER_COMMIT listeners, and where does the outbox pattern fit?

level: principalimportance: nice to knowfreq 22%

answer

  1. AFTER_COMMIT = same thread, post-commit
  2. @Async offloads, hides exceptions
  3. post-commit throw only logs
  4. crash window = lost side effect
  5. outbox / Modulith registry = durable retryable

basics

~20 s

By default the AFTER_COMMIT listener runs synchronously in the committing thread; adding @Async moves it to another thread. Exceptions after commit only get logged. If the process crashes after commit but before the side effect, the side effect is lost — the transactional outbox pattern closes that gap.

solid answer

~50 s

A default @TransactionalEventListener at AFTER_COMMIT runs synchronously on the same thread that committed, after the commit completes. Adding @Async offloads it to the executor, decoupling latency but losing the caller's thread context and any exception visibility. In either case, exceptions thrown post-commit cannot roll back the already-committed transaction — they're just logged (or handled by the async exception handler). This means AFTER_COMMIT gives you 'at-most-once, best-effort' side effects: if the JVM dies between commit and the side effect (or the broker publish fails), the event is lost with no automatic retry. When you need reliable delivery, the transactional outbox pattern is the answer: within the same transaction, write the intended message to an outbox table (atomic with the business data), then a separate poller/relay reads and dispatches it with retries. Spring Modulith's event publication registry implements exactly this on top of @TransactionalEventListener.

code

java · 19 lines
java
@Component
class NotificationHandler {

    // Offloaded to the async executor AFTER commit; failures go to
    // AsyncUncaughtExceptionHandler and cannot roll back the committed data.
    @Async
    @TransactionalEventListener
    public void onOrderPlaced(OrderPlacedEvent e) {
        broker.publish(e); // best-effort, at-most-once
    }
}

// For reliable delivery, write to an outbox in the SAME tx instead:
@Transactional
public void placeOrder(Order o) {
    orderRepo.save(o);
    outboxRepo.save(OutboxMessage.of(new OrderPlacedEvent(o.getId())));
    // a separate relay polls outboxRepo and publishes with retries
}

go deeper

for a junior

Knows AFTER_COMMIT runs after commit and @Async moves work off-thread.

for a middle

Understands post-commit exceptions can't roll back committed data.

for a senior

Explains the crash/durability window and best-effort semantics.

for a principal

Designs for reliability with the outbox pattern or Spring Modulith's event publication registry and reasons about at-least-once + idempotency.

## Threading: synchronous by default, @Async to offload At `AFTER_COMMIT`, the listener executes **synchronously inside the transaction's completion sequence**, on the **same thread** that performed the commit — so a slow handler adds latency to the committing request. Adding `@Async` (with `@EnableAsync`) reroutes the handler to a `TaskExecutor`, so it runs on a separate thread and the committing thread returns immediately. Trade-off: you lose the request thread's context (security context, MDC, request scope) unless propagated, and the caller cannot observe failures. ## Exception semantics after commit Once `AFTER_COMMIT` fires, the transaction is **already committed and unrecoverable**. An exception thrown by the listener: - Synchronous case: propagates out of the completion callback and is **logged**; it does not (and cannot) roll back the committed data. It may surface as an error to the original caller depending on the transaction infrastructure, but the data stays committed. - @Async case: goes to the `AsyncUncaughtExceptionHandler`; the caller never sees it. This is fundamentally **best-effort / at-most-once** delivery of the side effect. ## Durability gap Because the side effect (e.g., publishing to Kafka/RabbitMQ, calling another service) happens **after** the DB commit and **outside** any transaction, there is a window: commit succeeds, then the process crashes or the broker is unreachable → the side effect **never happens** and is **not retried**. Conversely, BEFORE_COMMIT can't help either, because then a broker publish would happen before commit and could succeed even if the commit later fails (dual-write problem). ## The transactional outbox pattern To get reliable, exactly-effectively-once side effects across a DB and a message broker: 1. In the **same** business transaction, insert a row into an **outbox** table describing the message. This is atomic with the business data — both commit or both roll back. 2. A separate **relay/poller** (or CDC on the outbox table) reads unsent rows **after** commit and publishes them, marking them sent, with **retries** and idempotency. This converts the unreliable post-commit publish into a durable, retryable one, at the cost of at-least-once semantics downstream (consumers must dedupe). ## Spring Modulith connection Spring Modulith's **Event Publication Registry** builds this on top of `@TransactionalEventListener`: it persists an event-publication row transactionally when a transactional listener is registered, marks it completed when the listener succeeds, and can **republish incomplete** ones on restart — effectively a framework-provided outbox for in-process listeners. This matters when you want AFTER_COMMIT semantics but also crash-resilience. ## Design guidance - Cheap, idempotent, tolerable-to-lose side effect → plain AFTER_COMMIT (optionally @Async). - Must-not-lose cross-system side effect → outbox (or Spring Modulith event publication registry). - Must be atomic with the same DB → BEFORE_COMMIT / same transaction, no messaging.

  • Why can't you just publish to the broker in a BEFORE_COMMIT listener to guarantee delivery?
    That reintroduces the dual-write problem: the broker publish could succeed and then the DB commit fail (or vice versa), leaving the two systems inconsistent. The outbox keeps the message write atomic with the data and dispatches after commit.
  • What guarantee does the transactional outbox give downstream consumers?
    At-least-once delivery — the relay retries until success — so consumers must be idempotent / deduplicate on a message id.

saying these in an interview costs you the question

  • Believing AFTER_COMMIT guarantees the side effect happens even on crash
  • Thinking @Async lets exceptions roll back the committed transaction
  • Assuming publishing in BEFORE_COMMIT solves the dual-write problem

context