Asynchronous messaging scenarios (order processing via a broker), WebFlux and Project Reactor (Mono/Flux, event loop, when reactive pays off vs virtual threads), back-pressure in Reactive Streams, designing a scalable real-time processing pipeline, @Scheduled and @Async with properly configured executors, and heavy asynchronous image processing done safely.
Published September 25, 2026
Senior candidates are expected to say when not to use reactive programming. With Java 21 virtual threads, blocking-style code scales for most I/O-bound services. Also:
@Async and @Scheduled deliberately;Short answer: Order placement in e-commerce. The checkout API validates and saves the order, then publishes an OrderPlaced event to Kafka or RabbitMQ, and immediately returns 201/202 to the user. Separate consumers handle the slow or unreliable follow-ups at their own pace: sending emails and SMS, updating inventory, awarding loyalty points, fraud checks, analytics.
Key points to cover:
@KafkaListener(topics = "orders.placed", groupId = "notifications")
public void on(OrderPlaced event) {
if (!processed.markIfNew(event.eventId())) return; // idempotent consumer
notifications.sendOrderConfirmation(event.orderId());
}
Learn it in depth → Event-Driven Architecture Patterns
Short answer: Spring WebFlux (spring-boot-starter-webflux) is a non-blocking web stack, running on Netty by default, built on Project Reactor:
Mono<T> carries 0 or 1 item, and Flux<T> carries 0..N items. Both are Reactive Streams publishers.RouterFunction).WebClient for non-blocking HTTP.@GetMapping(value = "/prices/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<PriceTick> stream(@RequestParam String symbol) {
return priceService.ticks(symbol) // an infinite Flux from a broker
.sample(Duration.ofMillis(250)) // throttle for the browser
.onBackpressureLatest();
}
Key points to cover:
spring.threads.virtual.enabled=true, Boot 3.2+) give most of the scalability to ordinary blocking MVC code. So choose WebFlux mainly for streaming and back-pressure needs.Short answer: Reactive Streams is a pull-based contract. A subscriber calls Subscription.request(n) to say how many items it can take, and the publisher must not emit more than was requested. Demand propagates upstream through the operator chain. So a slow consumer naturally slows the producer, instead of piling items into memory.
Key points to cover:
onBackpressureBuffer(max, …): buffer, with a bound;onBackpressureDrop();onBackpressureLatest(): keep only the newest;sample/window/buffer: batch or throttle;limitRate(n): prefetch tuning.request(n) between services. Plain HTTP JSON arrays don't.publishOn/subscribeOn move work between schedulers. Their queues are bounded too.Short answer: Build a streaming pipeline from source to sink:
Function<Flux<In>, Flux<Out>>), or accept WebSocket or RSocket streams.flatMap(…, concurrency) to bound the in-flight calls.WebClient, or a reactive Redis cache, for lookups.window/bufferTimeout for micro-batches.groupBy per key for ordered, per-entity processing.timeout, retryWhen(Retry.backoff(...)), and onErrorContinue avoided, in favour of explicit error-to-DLQ handling.Common trap: calling a blocking library (JDBC, a legacy SDK) inside map or flatMap. If you must, wrap it with Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()), and treat it as a design smell.
Short answer:
@EnableScheduling, then @Scheduled methods:
fixedRate: start every N, regardless of the previous run;fixedDelay: wait N after the previous run finishes;initialDelay;cron (6 fields in Spring, including seconds), with a zone.@EnableAsync. Methods marked @Async run on a TaskExecutor, returning void or CompletableFuture<T>.TaskScheduler/TaskExecutor. Boot auto-configures ThreadPoolTaskScheduler (by default a single thread, so one slow job delays the others) and ThreadPoolTaskExecutor, tuned with spring.task.scheduling.* and spring.task.execution.*, or virtual threads.spring:
task:
scheduling:
pool:
size: 4
execution:
pool:
core-size: 8
max-size: 16
queue-capacity: 500
Key points to cover:
@Scheduled job. Use ShedLock (a database or Redis lock), Quartz in clustered mode, or a Kubernetes CronJob, so a job runs once.Short answer: For quick wins, use @Async with a dedicated, bounded executor. For robustness, use a queue-backed job:
ImageJob(PENDING) row, and return 202 Accepted with /jobs/{id}, or notify later through a webhook or WebSocket.@Async("imageExecutor") method.CallerRunsPolicy, or reject with 429 or 503).@Configuration @EnableAsync
class AsyncConfig {
@Bean("imageExecutor")
ThreadPoolTaskExecutor imageExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(Runtime.getRuntime().availableProcessors());
ex.setMaxPoolSize(Runtime.getRuntime().availableProcessors());
ex.setQueueCapacity(200); // bounded: back-pressure instead of OOM
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.setThreadNamePrefix("img-");
ex.setTaskDecorator(new ContextCopyingDecorator()); // MDC/trace/security context
ex.initialize();
return ex;
}
}
@Service
class ImageProcessor {
@Async("imageExecutor")
public CompletableFuture<Void> process(UUID jobId) {
// download from storage → resize/compress (thumbnails, WebP) → upload variants → mark job DONE
return CompletableFuture.completedFuture(null);
}
}
Common traps:
@Async on a method called from the same class (the proxy is bypassed, so it runs synchronously).Q: WebFlux or MVC with virtual threads for a new service? A: Default to MVC + virtual threads: simpler code, blocking libraries (JDBC, JPA) work, and it scales for I/O-bound work. Choose WebFlux for streaming (SSE, WebSockets), back-pressure-sensitive pipelines, gateways, or a codebase that's already reactive end to end.
Q: How do you handle exceptions from @Async methods?
A: For CompletableFuture returns, the caller handles them (exceptionally, handle). For void methods, configure an AsyncUncaughtExceptionHandler (through AsyncConfigurer). Otherwise the exceptions are only logged.
Q: How do you propagate the MDC, tracing and security context to async threads?
A: Use a TaskDecorator that captures the context on submit and restores it in the worker. Micrometer's ContextSnapshot or ContextPropagatingTaskDecorator does this for observations. For security, use DelegatingSecurityContextAsyncTaskExecutor. Reactor uses its Context, plus automatic context propagation (Hooks).
Q: fixedRate vs fixedDelay when a run takes longer than the interval?
A: With the default single-threaded scheduler, fixedRate runs back-to-back, catching up with no overlap. fixedDelay always waits the full delay after completion. Concurrent overlap only happens if you add @Async or use a larger pool.