Scope

How work leaves the request thread and how components talk without calling each other directly — in-process application events (02 - IoC Container, Beans & Dependency Injection beans publishing to beans), @Async/@Scheduled background execution inside one JVM, and external brokers (Kafka, RabbitMQ) between services. The senior material is the failure modes: proxy self-invocation, exceptions swallowed by void async methods, schedulers firing on every clustered instance, and above all the dual-write problem that the transactional outbox pattern exists to solve.

Related: Spring Boot, 07 - Transactions & Data Consistency, 13 - Resilience & Production Readiness

The dual-write problem: a crash between the DB commit and the broker publish loses the event — the outbox pattern commits both rows in one local transaction and relays the second one after.


1. Three mechanisms, one question: what’s the coupling boundary?

“Messaging” spans three very different tools, and picking the wrong one is a design smell interviewers listen for. The deciding question is where the boundary is and what happens if the other side is down.

MechanismBoundaryDeliverySurvives a crash?
In-process events (ApplicationEventPublisher)between beans in one JVMin-memory method callsNo — events live only in heap
@Async / @Scheduledoff the caller’s thread, same JVMin-memory task queueNo — queued tasks lost on restart
External broker (Kafka / RabbitMQ)between separate processes/servicespersisted on the brokerYes — the broker is durable storage

The framing interviewers want

In-process events and @Async are decoupling within a deployable, not durability — if the JVM dies mid-work, that work is gone. The moment you need the work to survive a crash or cross a service boundary, you need a broker. Choosing an in-memory @EventListener for something that must not be lost (a payment side-effect) is the classic mistake; choosing Kafka for two beans in the same app is the opposite over-engineering.


2. In-process application events

The ApplicationContext is itself an event bus. A bean publishes an event; any bean with a matching listener handles it. This decouples a producer from its consumers without either holding a reference to the other.

public record OrderPlaced(String orderId, BigDecimal total) {}   // a plain POJO event (Spring 4.2+)
 
@Service
class OrderService {
    private final ApplicationEventPublisher publisher;           // injected, not ApplicationEventPublisherAware
 
    OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; }
 
    public void place(Order order) {
        // ... persist the order ...
        publisher.publishEvent(new OrderPlaced(order.id(), order.total()));
    }
}
 
@Component
class InventoryListener {
    @EventListener                                               // type-safe; method name is free
    void on(OrderPlaced e) { /* reserve stock */ }
}

@EventListener is SYNCHRONOUS by default

publishEvent(...) does not return until every listener has run, on the caller’s thread, inside the caller’s transaction. A slow or throwing listener slows down — or rolls back — the code that published the event. People assume “event = fire-and-forget” and are surprised when a listener’s exception fails the original request.
Mitigation: make a listener asynchronous by adding @Async (see §4) and @EnableAsync. Note that once it’s @Async it runs on another thread, outside the publisher’s transaction, and its exceptions no longer propagate to the caller — a deliberate trade-off, not a free upgrade.


3. @TransactionalEventListener: firing after the commit

A plain @EventListener runs inside the publisher’s transaction, so a listener that calls an external system will have acted even if the transaction later rolls back. @TransactionalEventListener binds the listener to a transaction phase instead.

TransactionPhaseListener runs…Use for
BEFORE_COMMITjust before commitlast-chance validation / same-tx writes
AFTER_COMMIT (default)after a successful commitside-effects that must only happen if the data really persisted
AFTER_ROLLBACKafter a rollbackcompensation, alerting
AFTER_COMPLETIONafter commit or rollbackcleanup regardless of outcome
@TransactionalEventListener                 // default phase = AFTER_COMMIT
void on(OrderPlaced e) {
    emailClient.sendConfirmation(e.orderId());   // only sent if the order truly committed
}

AFTER_COMMIT is the honest default for external side-effects

“Send the email / call the shipping API only if the order actually committed” is exactly AFTER_COMMIT. If there is no active transaction, the listener is silently skipped unless you set fallbackExecution = true — a subtle gotcha when the same method is invoked both inside and outside a transaction. Crucially, AFTER_COMMIT does not make the side-effect durable: the commit succeeds, then the JVM can die before the email sends. That gap is the dual-write problem (§9), and it is why AFTER_COMMIT on its own is not a delivery guarantee.


4. @Async: work off the caller’s thread

@Async runs a method on a task executor instead of the calling thread. It is proxy-based — Spring wraps the bean in an AOP proxy whose overridden method dispatches to the executor.

@Configuration
@EnableAsync                                                   // required, or @Async is ignored
class AsyncConfig {
    @Bean
    ThreadPoolTaskExecutor appExecutor() {                     // name it; qualify with @Async("appExecutor")
        var ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(8);
        ex.setMaxPoolSize(16);
        ex.setQueueCapacity(500);                              // bounded — see backpressure (§10)
        ex.setThreadNamePrefix("app-async-");
        ex.initialize();
        return ex;
    }
}
 
@Service
class ReportService {
    @Async("appExecutor")
    CompletableFuture<Report> build(String id) {               // return a Future to get the result / errors back
        return CompletableFuture.completedFuture(generate(id));
    }
}

Self-invocation is a no-op — the proxy is bypassed

Because @Async works through a proxy, calling an @Async method from another method of the same bean (this.build(id)) runs it synchronously on the current thread — the call never leaves the object, so the proxy that would have dispatched to the executor is never involved. This is the same proxy limitation that bites @Transactional and @Cacheable; see 02 - IoC Container, Beans & Dependency Injection. The method appears to “not be async” for no visible reason.
Mitigation: call the async method from a different bean, inject a reference to self (via ApplicationContext or a self-injected proxy), or — for pervasive cases — switch to AspectJ load-time weaving (@EnableAsync(mode = AdviceMode.ASPECTJ)).

@Async void swallows every exception

A void (or Future-less) @Async method that throws sends its exception nowhere — there is no caller holding a Future to observe it, so it vanishes. Silent, permanent data loss with no stack trace in the request path.
Mitigation: return CompletableFuture<T> so the caller can observe failures via .exceptionally(...)/.get(); for genuinely void methods, register an AsyncUncaughtExceptionHandler (below) so at minimum it is logged/alerted.

@Configuration
@EnableAsync
class AsyncErrorConfig implements AsyncConfigurer {
    @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> log.error("async {} failed", method.getName(), ex);
    }
}

Virtual-thread executors for I/O-bound async (JDK 21+)

If your async work is I/O-bound (HTTP calls, DB), a pool sized for threads is the wrong model. On JDK 21+ back it with virtual threads — SimpleAsyncTaskExecutor supports them (setVirtualThreads(true)), and Spring Boot’s spring.threads.virtual.enabled=true switches the app’s executors over wholesale. You then stop tuning pool sizes and let the platform schedule. [HIGH-CHURN: JDK 17 is the Boot 4 baseline; virtual threads require Java 21+ — verify your runtime.]


5. @Scheduled: periodic work, and the cluster trap

@Scheduled (with @EnableScheduling) runs a method on a timer. Know the three triggers cold:

AttributeInterval measured…Behaviour if a run overruns
fixedRatebetween successive start timesruns pile up / overlap (single scheduler thread serializes)
fixedDelaybetween end of one run and start of nextnaturally spaces out; no overlap
cronwall-clock expression (0 0 3 * * *), optional zonecalendar-based; supports @daily macros
@Scheduled(fixedDelay = 30_000, initialDelay = 5_000)          // 30s AFTER each run finishes
void reconcile() { /* ... */ }
 
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Berlin")       // 03:00 Berlin time daily
void nightlyRollup() { /* ... */ }

@Scheduled fires on EVERY instance in a cluster

@Scheduled is per-JVM. Deploy the app across three pods and the nightly job runs three times — three emails, triple-charged invoices, three racing writes. Works perfectly in dev (one instance), corrupts data in production (many). This is one of the most common “it worked on my machine” incidents.
Mitigation: serialize the job cluster-wide. The pragmatic default is ShedLock (a distributed lock in your DB/Redis via @SchedulerLock, so only one instance runs each fire). Alternatives: a leader-election mechanism (Spring Cloud / Kubernetes lease), or moving the job to a dedicated single-instance worker or an external scheduler (Quartz clustered, K8s CronJob).

@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "nightlyRollup", lockAtMostFor = "10m")   // ShedLock: only one instance wins
void nightlyRollup() { /* ... */ }

6. Kafka with spring-kafka

Kafka is a durable, partitioned, append-only log. Producers append records to a topic’s partitions; consumers read at their own pace by tracking an offset. Records are retained (by time/size) after being read, so many independent consumers can replay the same log.

@Component
class OrderConsumer {
    @KafkaListener(topics = "orders", groupId = "billing")     // consumer group "billing"
    void handle(ConsumerRecord<String, OrderPlaced> rec, Acknowledgment ack) {
        process(rec.value());
        ack.acknowledge();                                     // manual offset commit AFTER success
    }
}
@Service
class OrderProducer {
    private final KafkaTemplate<String, OrderPlaced> template;
    OrderProducer(KafkaTemplate<String, OrderPlaced> t) { this.template = t; }
 
    void publish(OrderPlaced e) {
        template.send("orders", e.orderId(), e);               // key = orderId → same partition → ordered
    }
}

Two senior points:

  • Consumer groups = scaling + ordering unit. Kafka assigns each partition to exactly one consumer within a group, so concurrency is capped at the partition count; add a second group and it gets its own full copy of the stream. The message key decides the partition, and ordering is only guaranteed per partition — so keying by orderId keeps one order’s events in order while still parallelising across orders.
  • Offset commit is your delivery lever. The default AckMode is BATCH (commit after each poll batch). For at-least-once with precise control, use MANUAL/MANUAL_IMMEDIATE and ack.acknowledge() after successful processing. Commit before processing and a crash loses the record (at-most-once); commit after and a crash redelivers it (at-least-once).

7. RabbitMQ with spring-amqp

RabbitMQ is a broker with queues: a producer publishes to an exchange, which routes (by binding/routing-key) into queues, and consumers pull from queues. Unlike Kafka, a message is typically removed once acknowledged — it’s a work queue, not a replayable log.

@Component
class PaymentConsumer {
    @RabbitListener(queues = "payments")
    void handle(PaymentMessage msg) {                          // container acks on normal return
        process(msg);                                          // throw → nack → retry/DLQ per config
    }
}

@RabbitListener uses AcknowledgeMode.AUTO by default: return normally and the message is acked; throw and it is nacked (redelivered or dead-lettered depending on configuration). Rabbit shines for per-message routing, priorities, and competing-consumer work queues; Kafka shines for high-throughput, replayable event streams consumed by many independent readers.


8. Delivery semantics: at-least-once is the real world

Every interview probes this. There are three theoretical guarantees; only two are practical.

GuaranteeMechanicsReality
At-most-oncecommit/ack before processingfast, but loses messages on crash — rarely acceptable
At-least-oncecommit/ack after processingthe practical default; a crash after work-but-before-ack redelivers → duplicates
Exactly-oncebroker transactions + dedupnarrow, costly, and not end-to-end once your side-effects touch external systems

A non-idempotent consumer turns redelivery into corruption

Under at-least-once, a message will be delivered more than once eventually (consumer crashes after processing, before commit; a rebalance re-reads; a retry fires). If handle() does balance += amount or INSERT without a uniqueness guard, the redelivery double-charges / double-inserts. The bug is invisible until the day a consumer restarts mid-batch.
Mitigation — idempotent consumers. Carry a stable idempotency key (message id / business key) and make the effect idempotent: INSERT ... ON CONFLICT DO NOTHING, an UPSERT, or a processed-ids table checked in the same transaction as the write. Then redelivery is a no-op, and you can stop chasing “exactly-once” you can’t actually get.

Why exactly-once is largely a myth end-to-end

Kafka’s “exactly-once semantics” holds within the Kafka boundary (idempotent producer + transactional read-process-write between Kafka topics). The instant your consumer sends an email, charges a card, or writes to a different database, that external effect isn’t inside the broker’s transaction — so the honest architecture is at-least-once delivery + idempotent processing, which is effectively exactly-once from the business’s point of view without pretending the broker gives it to you.


9. The dual-write problem and the transactional outbox

The single most important distributed-systems pattern in this module.

Dual-write: DB and broker can't be committed atomically

The natural code is “save the order, then publish the event”:

orderRepo.save(order);              // (1) commits to Postgres
kafka.send("orders", event);        // (2) sends to Kafka — SEPARATE system

There is no shared transaction across two systems. If (2) fails (broker down, pod killed between the two lines), the DB has the order but no event was ever sent — inventory and billing never hear about it. Reverse the order and a broker success followed by a DB rollback publishes a phantom event for an order that doesn’t exist. You cannot make two independent commits atomic, and @Transactional around both does not help — Kafka isn’t in that transaction.

Mitigation — the Transactional Outbox

Write the event as a row in an outbox table in the same local DB transaction as the business change. Now the state change and the “intent to publish” commit atomically — one transaction, one system. A separate relay (a poller, or Change-Data-Capture like Debezium tailing the WAL) reads unsent outbox rows and publishes them to the broker, marking them sent. Publishing is retried until it succeeds → at-least-once delivery with no lost or phantom events. Consumers must still be idempotent (§8), because the relay can publish a row twice if it crashes after sending but before marking it sent. This is the concrete payoff of the local-transaction guarantees in 07 - Transactions & Data Consistency.

@Transactional                                   // ONE local transaction — both rows or neither
public void placeOrder(Order order) {
    orderRepo.save(order);
    outboxRepo.save(new OutboxEvent("orders", order.id(), toJson(new OrderPlaced(...))));
}
// A relay (poller / Debezium CDC) later publishes unsent outbox rows to Kafka and marks them sent.

10. Retries, dead-letters, ordering, backpressure

The operational concerns that separate a demo from production.

  • Retries. Transient failures (a downstream blip) should be retried with backoff, not a tight loop. Spring Kafka’s DefaultErrorHandler and Rabbit’s retry interceptor give bounded retries with exponential backoff; wrap external calls with the resilience patterns in 13 - Resilience & Production Readiness. Distinguish retryable (timeout) from non-retryable (malformed payload) — retrying a poison message forever blocks the partition/queue.
  • Dead-letter queues (DLQ). After N failed attempts, route the message to a dead-letter topic/queue rather than blocking or dropping it. Spring Kafka’s DeadLetterPublishingRecoverer and Rabbit’s DLX give you this. A DLQ you never monitor is just a slow data-loss bug — alert on its depth.
  • Ordering. Only guaranteed per Kafka partition / per Rabbit queue with a single consumer. Concurrency and ordering are in tension: parallel consumers reorder. Key related events onto the same partition when order matters, and accept that unrelated events interleave.
  • Backpressure. A fast producer and slow consumer will overflow something. Bound your queues — a ThreadPoolTaskExecutor with queueCapacity and a sensible RejectedExecutionHandler (e.g. CallerRunsPolicy to slow the producer) beats an unbounded queue that becomes an OutOfMemoryError. Kafka provides natural backpressure (the consumer pulls at its own pace); an unbounded in-JVM @Async queue does not.

11. In-process events vs Kafka vs RabbitMQ

DimensionIn-process eventsKafka (log)RabbitMQ (queue)
Boundaryone JVMacross servicesacross services
Durable / survives crashNo (in-heap)Yes (persisted log)Yes (persisted queue)
Modelobserver / pub-subappend-only log, pull, replayablequeue, push, consume-and-remove
Multiple independent readersyes (many listeners)yes (many consumer groups, each full copy)needs fan-out exchange per consumer
Replay / re-read historynoyes (offsets, retention)no (once acked, gone)
Orderingcaller orderper-partitionper-queue
Throughputin-memory (highest)very highhigh
Per-message routing / priorityn/alimited (keys)rich (exchanges, routing keys, priorities)
When to pick whichdecouple beans inside one deployable; no durability neededevent streaming / high-volume, many consumers, replay, audit log, event sourcingtask/work queues, complex routing, RPC-style, per-message priorities, competing consumers

The committed recommendation

Reach for in-process events to decouple beans in a single service and nothing more — never for anything that must survive a restart. Choose Kafka when the workload is a stream consumed by multiple independent readers, needs replay/audit, or is genuinely high-throughput. Choose RabbitMQ for work-queue semantics — competing consumers pulling discrete tasks, rich routing, or priorities. And regardless of broker, design for at-least-once + idempotent consumers, and cross the DB→broker boundary through the outbox (§9), not a naive dual write.


12. Caveats & risk mitigation (summary)

  • In-memory ≠ durable (§1). Events and @Async tasks vanish on crash; if the work must not be lost, it belongs on a broker.
  • Proxies don’t self-invoke (§4). @Async (and @Transactional, @Cacheable) do nothing when called from within the same bean — cross a bean boundary.
  • Never @Async void without a handler (§4). Return a Future or register AsyncUncaughtExceptionHandler, or exceptions disappear.
  • @Scheduled is per-instance (§5). In a cluster, guard cluster-wide jobs with ShedLock or leader election.
  • Assume redelivery (§8). At-least-once is the default; make every consumer idempotent with an idempotency key.
  • Don’t dual-write (§9). Use the transactional outbox to make the state change and the publish atomic; a relay handles delivery.
  • DLQ + backoff + bounded queues (§10). Retry transient failures, dead-letter poison messages, and never run an unbounded in-JVM queue.
  • Version facts are volatile. Spring Boot 4.1.x / Framework 7.0 / spring-kafka & spring-amqp 4.1.x / Jakarta EE 11 / JDK 17 baseline (virtual threads Java 21+) are current as of mid-2026 — re-verify exact GA versions and any API details before relying on them.

13. In practice

// Outbox-backed order flow with an idempotent, manually-acked Kafka consumer.
@Service
class OrderService {
    private final OrderRepository orders;
    private final OutboxRepository outbox;
 
    OrderService(OrderRepository o, OutboxRepository ob) { this.orders = o; this.outbox = ob; }
 
    @Transactional                                              // ONE local tx: state + intent-to-publish (§9)
    public void place(Order order) {
        orders.save(order);
        outbox.save(OutboxEvent.of("orders", order.id(), new OrderPlaced(order.id(), order.total())));
    }
}
 
@Component
class BillingConsumer {
    @KafkaListener(topics = "orders", groupId = "billing")
    void handle(ConsumerRecord<String, OrderPlaced> rec, Acknowledgment ack) {
        var e = rec.value();
        if (processed.putIfAbsent(e.orderId())) {               // idempotency key → redelivery is a no-op (§8)
            charge(e.orderId(), e.total());
        }
        ack.acknowledge();                                      // at-least-once: commit AFTER success (§6)
    }
}

Interview talking points to be able to defend:

  • “In-process events and @Async decouple beans but aren’t durable — if the JVM dies the work is gone; durability means a broker.”
  • “@Async and @Transactional are proxy-based, so self-invocation is a no-op — I call across a bean boundary, and never leave an @Async void without an exception handler.”
  • “@Scheduled runs on every instance in a cluster; I guard cluster-wide jobs with ShedLock or leader election.”
  • “At-least-once is the practical default, so I make consumers idempotent with an idempotency key — end-to-end exactly-once is a myth once external side-effects are involved.”
  • “I never dual-write DB then broker; I use a transactional outbox so the state change and the publish commit atomically, and a relay delivers with retries.”

Sources