Messaging, Async & Scheduling

Difficulty

@Async, combined with @EnableAsync on a configuration class, lets a method run on a separate thread, so the caller doesn't block waiting for it to complete:

@Configuration
@EnableAsync
class AsyncConfig { }

@Service
class NotificationService {
    @Async
    void sendWelcomeEmail(String email) {
        // runs on a separate thread — sendWelcomeEmail() returns to the caller immediately
    }

    @Async
    CompletableFuture<Boolean> sendWelcomeEmailWithResult(String email) {
        boolean sent = emailClient.send(email);
        return CompletableFuture.completedFuture(sent); // caller can .get()/.thenApply() on this
    }
}

Pitfalls to watch for:

  1. Self-invocation bypasses the proxy — exactly like @Transactional. @Async is implemented via the same AOP proxy mechanism, so calling an @Async method on this, from within the same class, calls the real method directly and synchronously — the annotation is silently ignored:
@Service
class NotificationService {
    void processSignup(User user) {
        sendWelcomeEmail(user.getEmail()); // NOT async — bypasses the proxy, runs synchronously!
    }
    @Async
    void sendWelcomeEmail(String email) { ... }
}

Fix: move the @Async method to a separate bean.

  1. Exceptions from a void-returning @Async method are silently lost by default — there's no caller waiting on a Future to propagate the exception back to. Configuring a custom AsyncUncaughtExceptionHandler (via AsyncConfigurer) is necessary to actually observe/log these failures, otherwise they vanish without a trace:
@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> log.error("Async method {} threw", method, ex);
    }
}

Methods returning CompletableFuture/Future don't have this problem — the exception is captured in the future itself, retrievable via .get()/.exceptionally(...).

  1. No custom executor configured → an unbounded, unpooled default. Without explicitly defining a TaskExecutor bean, Spring falls back to SimpleAsyncTaskExecutor, which creates a brand-new thread for every single invocation, with no pooling and no upper bound — under any real load, this can exhaust system resources quickly. Always configure an explicit, bounded ThreadPoolTaskExecutor:
@Bean(name = "taskExecutor")
Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(20);
    executor.setQueueCapacity(100);
    return executor;
}

@Scheduled, combined with @EnableScheduling, lets a method run automatically on a defined schedule, without any external cron daemon or scheduling infrastructure — Spring manages the timing internally via a TaskScheduler:

@Configuration
@EnableScheduling
class SchedulingConfig { }

@Component
class ReportJob {
    @Scheduled(fixedRate = 60000)        // every 60 seconds, measured from start-to-start
    void runEveryMinute() { ... }

    @Scheduled(fixedDelay = 60000)       // 60 seconds after the *previous run finished*
    void runWithDelay() { ... }

    @Scheduled(cron = "0 0 2 * * *")     // cron expression — every day at 2:00 AM
    void runNightlyReport() { ... }
}

fixedRate measures the interval between successive start times (so if a run takes longer than the interval, the next one starts immediately after the previous finishes, rather than overlapping); fixedDelay measures the interval from one run's completion to the next run's start.

The major limitation: multi-instance deployments. @Scheduled has no built-in awareness of other instances of the same application running elsewhere. If the application is scaled horizontally to, say, 4 instances for availability/load reasons, all 4 instances will independently trigger the same @Scheduled method at the same scheduled time — which is almost never the intended behavior for a task that should logically run exactly once across the whole fleet (e.g., "generate today's single daily report," "send one batch of reminder emails," not four duplicate copies of each).

Common solutions:

  1. A distributed lock library like ShedLock — wraps the scheduled method so that, across all instances, only whichever one acquires the lock first actually executes the method for that scheduled trigger; the others detect the lock is held and simply skip that run:
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "nightlyReport", lockAtMostFor = "PT30M")
void runNightlyReport() { ... }
  1. Delegate scheduling to a single, external, dedicated trigger source (a Kubernetes CronJob that invokes exactly one instance/endpoint, an external workflow scheduler) instead of relying on @Scheduled running independently inside every replica of the application.

  2. Restrict the scheduled logic to run only on a designated "leader" instance, using a leader-election mechanism — more complex to set up than ShedLock for most cases, but sometimes appropriate in systems that already have leader election available for other reasons.

Practical guidance: @Scheduled alone is fine for a single-instance deployment, or for scheduled work that's genuinely safe to run redundantly on every instance (e.g., refreshing an in-memory, per-instance cache) — but the moment a task must run exactly once across a horizontally-scaled fleet, @Scheduled by itself is the wrong tool without adding one of these coordination mechanisms on top.

Spring's built-in application event mechanism (ApplicationEventPublisher.publishEvent() + @EventListener, detailed under Spring Core & DI) is worth specifically distinguishing from external messaging (Kafka/RabbitMQ), since both solve a "decouple the producer from the consumer" problem but at very different scopes.

Application events are in-process only — they exist entirely within a single JVM/application instance's ApplicationContext. A UserRegisteredEvent published in one instance is only visible to @EventListener methods registered in that same instance; a separate instance of the same application (in a horizontally-scaled deployment) never sees it at all.

@Service
class UserService {
    private final ApplicationEventPublisher publisher;
    void register(User user) {
        // ... save user ...
        publisher.publishEvent(new UserRegisteredEvent(user.getId())); // visible only within this JVM
    }
}

When application events are the right tool:

  • Decoupling side effects that only need to happen within the same application instance that triggered them — e.g., invalidating a local in-memory cache after an update, triggering a secondary in-process computation, or simply organizing code so unrelated concerns (sending a notification, writing an audit entry) aren't hard-wired directly into the primary operation's method body.
  • No additional infrastructure (a message broker, network reliability concerns, serialization format) is needed at all — it's essentially free, using plain in-memory method dispatch under the hood.

When you need real messaging (Kafka/RabbitMQ) instead:

  • The consumer needs to run in a different service or process entirely — application events never cross a JVM boundary, let alone a network boundary.
  • You need durability — if no instance is currently running (or the instance crashes right after publishing), an application event is simply lost forever, with no persistence or replay; a message broker persists messages until they're actually consumed (and acknowledged).
  • You need to scale consumers independently from producers, or have multiple different services (not just multiple listeners within one service) react to the same event.

Practical guidance: default to Spring application events for decoupling within a single service/instance — it's simpler, has zero extra infrastructure, and is easy to reason about; reach for an actual message broker only once the communication genuinely needs to cross process/service boundaries or requires durability guarantees an in-memory event can't provide.

spring-kafka wraps the plain Apache Kafka Java client with Spring-friendly abstractions, removing most of the manual producer/consumer setup boilerplate.

Configuration (application.yml):

spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: order-service-group
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer

Producing messages with KafkaTemplate:

@Service
class OrderEventPublisher {
    private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;

    OrderEventPublisher(KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    void publish(OrderPlacedEvent event) {
        kafkaTemplate.send("order-events", event.orderId(), event)
            .whenComplete((result, ex) -> {
                if (ex != null) log.error("Failed to publish order event", ex);
            });
    }
}

Consuming messages with @KafkaListener:

@Component
class OrderEventConsumer {
    @KafkaListener(topics = "order-events", groupId = "inventory-service-group")
    void handleOrderPlaced(OrderPlacedEvent event) {
        inventoryService.reserveStock(event.orderId());
        // by default, the offset is committed automatically after this method returns successfully
    }
}

Key concepts worth understanding:

  • Consumer groups: multiple instances of the same service sharing the same groupId automatically divide up a topic's partitions among themselves — each message is delivered to only one consumer instance within a given group, which is what makes horizontal scaling of consumers work correctly (as opposed to every instance redundantly processing every message).
  • Serialization: producer and consumer must agree on serializer/deserializer pairs (JsonSerializer/JsonDeserializer being a common, simple default for structured messages) — a mismatch here is a common source of "consumer silently fails to deserialize" issues.
  • Delivery/processing guarantees: by default, spring-kafka commits consumer offsets automatically after a listener method returns without throwing — meaning careful thought is needed about idempotency (can this handler safely process the same message twice, since Kafka's at-least-once delivery model means redelivery is possible after certain failure scenarios) and error handling (a DefaultErrorHandler can be configured with retry/backoff and a dead-letter-topic strategy for messages that repeatedly fail processing).

When Kafka fits well: high-throughput event streaming, durable event logs multiple independent consumers can replay/process at their own pace, and scenarios where strict message ordering within a partition (e.g., all events for a given order ID) matters.

Related Resources

spring-amqp/spring-rabbit provides Spring-friendly abstractions over RabbitMQ, following the AMQP model of exchanges, queues, and bindings rather than Kafka's partitioned-topic-and-consumer-group model.

Declaring the topology (exchange, queue, and the binding connecting them) as beans:

@Configuration
class RabbitConfig {
    @Bean
    Queue orderQueue() { return new Queue("order.queue", true); } // durable queue

    @Bean
    TopicExchange orderExchange() { return new TopicExchange("order.exchange"); }

    @Bean
    Binding binding(Queue orderQueue, TopicExchange orderExchange) {
        return BindingBuilder.bind(orderQueue).to(orderExchange).with("order.placed.*");
    }
}

Producing messages with RabbitTemplate:

@Service
class OrderEventPublisher {
    private final RabbitTemplate rabbitTemplate;

    void publish(OrderPlacedEvent event) {
        rabbitTemplate.convertAndSend("order.exchange", "order.placed.created", event);
        // exchange name, routing key, and the message payload (auto-converted, typically to/from JSON)
    }
}

Consuming messages with @RabbitListener:

@Component
class OrderEventConsumer {
    @RabbitListener(queues = "order.queue")
    void handleOrderPlaced(OrderPlacedEvent event) {
        inventoryService.reserveStock(event.orderId());
        // acknowledged automatically on successful return, by default
    }
}

Key conceptual difference from Kafka — routing via exchanges: RabbitMQ's exchange is the component that decides how an incoming published message gets routed to zero, one, or multiple queues, based on the exchange type and the message's routing key:

  • Direct exchange: routes to queues bound with an exact matching routing key.
  • Topic exchange: routes based on wildcard-pattern matching against the routing key (order.placed.* matching order.placed.created, order.placed.updated, etc.) — used in the example above.
  • Fanout exchange: routes a message to every queue bound to it, regardless of routing key — a simple broadcast/pub-sub pattern.

This gives RabbitMQ very flexible, fine-grained routing topologies, in contrast to Kafka's simpler (but very high-throughput, replay-capable) topic-and-partition model.

When RabbitMQ tends to fit well: traditional task-queue/work-distribution scenarios, complex routing requirements (multiple consumer types needing different subsets of messages based on routing patterns), and cases valuing RabbitMQ's mature per-message acknowledgment and flexible dead-lettering/retry semantics over Kafka's strengths in extremely high-throughput, replayable event-log style streaming.