Next steps: WebFlux, messaging and native images
Choosing reactive or servlet, using WebClient correctly, integrating Kafka, building a GraalVM native image, and where to read next.
Servlet or reactive
| Dimension | Spring MVC | Spring WebFlux |
|---|---|---|
| Threading | One thread per request | Event loop, few threads |
| Blocking JDBC/JPA | The natural fit | Blocks the event loop - avoid |
| Best at | CPU work, existing JPA code | Many slow downstream calls, streaming |
| Debugging | Familiar stack traces | Reactive stack traces, needs practice |
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<PriceTick> stream() {
return Flux.interval(Duration.ofSeconds(1))
.flatMap(i -> pricing.latest("ACME"))
.onErrorResume(e -> Flux.empty());
}
var client = WebClient.builder()
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.USER_AGENT, "orders/1.0")
.build();
Mono<Quote> quote = client.get().uri("/quote/{s}", symbol)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, r -> Mono.error(new QuoteMissingException()))
.bodyToMono(Quote.class)
.timeout(Duration.ofSeconds(3))
.retryWhen(Retry.backoff(2, Duration.ofMillis(200)));💡
Nothing improves by being reactive by accident. Add it when you have a genuine concurrency-of-waiting problem - thousands of slow downstream calls, streaming responses, or SSE - not because it sounds faster.
Messaging with Kafka
@Component
class OrderEvents {
private final KafkaTemplate<String, OrderPlaced> template;
OrderEvents(KafkaTemplate<String, OrderPlaced> template) { this.template = template; }
void publish(OrderPlaced event) {
template.send("orders.placed", event.orderId(), event);
}
}
@Component
class BillingListener {
@KafkaListener(topics = "orders.placed", groupId = "billing")
void on(OrderPlaced event, Acknowledgment ack) {
if (processed.contains(event.eventId())) { ack.acknowledge(); return; } // idempotent
try {
billing.charge(event);
ack.acknowledge();
} catch (TransientException e) {
throw e; // let the container retry, then route to the DLT
}
}
}- Consumers are at-least-once. Every handler must be idempotent, keyed on the event id.
- The message key determines the partition, and therefore the ordering guarantee - key by aggregate id.
- Set
spring.kafka.listener.ack-modedeliberately, and configure a dead-letter topic so poison messages do not block a partition.
Native images
./gradlew nativeCompile # via GraalVM
# or a container build
./gradlew bootBuildImage --imageName=orders-native -Pnative
# run it
./build/native/nativeCompile/orders
# typical result: ~100 MB RSS, 50-100 ms to first response- Ahead-of-time compilation closes the world at build time, so reflection, dynamic proxies and some classpath scanning need hints.
spring-boot-starter-parentplus the AOT engine covers most of Spring's own reflection, but your code - custom reflection, JPA metamodels, JSON polymorphic types - often needs explicit hints.- Startup and memory improve dramatically; peak throughput does not. Native is for scale-to-zero and CLI-style workloads, not a free speed-up.
- Reading list: the Spring Boot reference on AOT and native images, the Spring for Apache Kafka reference, and the WebFlux section on backpressure.
FAQ
Can I mix MVC and WebFlux?
Yes, but only deliberately: a WebFlux app can use blocking repositories on a bounded elastic scheduler. Mixing them in one context usually means you have chosen the wrong model - pick the one that matches your data access.
Is a native image right for a typical CRUD service?
Usually no. The development friction of reflection hints is real, and the benefit is startup time and footprint - which matters for serverless and CLI, not for a long-running container behind a load balancer.
Related
Packaging and deploying a Spring Boot service Observability with Actuator, logging and metrics
Last refreshed 2026-09-18.