The Albertsons Cart & Checkout platform processes millions of cart operations daily across 2,300+ stores. For years, our services were built on blocking Spring MVC โ a model that worked until it didn't. As traffic grew and checkout latency became a competitive concern, we made the decision to migrate to a fully reactive stack built on Spring WebFlux and Kafka.
This is the story of that migration: the why, the how, and the things we got wrong before we got them right.
The Problem with Blocking I/O at Scale
Traditional Spring MVC uses a thread-per-request model. Each incoming HTTP request gets a thread from the pool, and that thread is blocked while waiting for database responses, downstream service calls, or Kafka message processing. At moderate traffic this is fine. At high traffic with multiple downstream dependencies per request, it becomes a bottleneck.
During peak grocery shopping hours โ Friday evenings, Saturday mornings โ our checkout services were exhausting thread pools. Requests queued. Latency spiked. We were throwing more hardware at a fundamentally inefficient model.
In a blocking model, threads spend most of their time waiting, not computing. Reactive programming inverts this: a small number of event-loop threads handle many concurrent operations by never blocking โ they register callbacks and move on.
Choosing Spring WebFlux
Spring WebFlux is Spring's reactive web framework, built on Project Reactor and Netty. It uses Mono (0 or 1 result) and Flux (0 to N results) as its core types, enabling non-blocking, backpressure-aware data streams.
We chose WebFlux over alternatives like Vert.x or Quarkus because our team had deep Spring expertise, and WebFlux is a natural evolution โ many patterns map directly from MVC. The learning curve is real, but it's manageable.
// Before: blocking Spring MVC controller @GetMapping("/cart/{sessionId}") public CartResponse getCart(@PathVariable String sessionId) { Cart cart = cartService.findBySession(sessionId); // blocks thread Promotions promos = promoService.getForCart(cart); // blocks again return CartResponse.of(cart, promos); } // After: reactive WebFlux controller @GetMapping("/cart/{sessionId}") public Mono<CartResponse> getCart(@PathVariable String sessionId) { return cartService.findBySession(sessionId) .zipWith(promoService.getForCart(sessionId)) .map(tuple -> CartResponse.of(tuple.getT1(), tuple.getT2())); }
The reactive version fetches the cart and promotions concurrently rather than sequentially โ a meaningful latency improvement when both calls take ~50ms each.
Kafka Integration: Event-Driven Checkout Flows
Cart operations are inherently event-driven: item added, quantity changed, coupon applied, checkout initiated. We modeled each of these as Kafka events and built a reactive consumer pipeline using Spring Kafka with reactive Kafka bindings via ReactorKafkaReceiver.
@Bean public Flux<ReceiverRecord<String, CartEvent>> cartEventStream() { ReceiverOptions<String, CartEvent> options = ReceiverOptions .<String, CartEvent>create(consumerProps()) .subscription(Collections.singleton("cart-events")); return KafkaReceiver.create(options).receive() .doOnNext(r -> r.receiverOffset().acknowledge()); } public Flux<CartState> processEvents(Flux<CartEvent> events) { return events .groupBy(CartEvent::getSessionId) .flatMap(group -> group.scan(CartState.empty(), CartState::apply)) .filter(CartState::isCheckoutReady); }
What We Got Wrong
Reactive programming has sharp edges, and we hit most of them:
- Blocking code in reactive chains. One block() call on a Netty event-loop thread deadlocks the application. We added Reactor's BlockHound to CI to catch this automatically.
- Error handling is different. Exceptions don't bubble up the same way. You need explicit onErrorResume and onErrorReturn operators throughout the chain.
- Debugging is harder. Stack traces in reactive code are notoriously unhelpful. We enabled Hooks.onOperatorDebug() in development and added structured logging with correlation IDs at each operator boundary.
- Not everything should be reactive. Simple CRUD services with low concurrency don't benefit much and pay the cognitive overhead cost. We kept some internal admin services on MVC.
Results
After migrating the core cart read path and checkout initiation flow to WebFlux + reactive Kafka:
- Peak-hour thread pool exhaustion incidents: eliminated
- Checkout service p99 latency: reduced significantly under load
- Horizontal scaling efficiency improved โ fewer pods handling more concurrent requests
- Downstream service timeouts handled gracefully with fallbacks instead of cascading failures
Key Takeaways
- Reactive is the right choice when you have high concurrency with I/O-bound operations and multiple downstream calls per request
- Use zipWith and zip to fan out concurrent calls โ sequential chaining defeats the purpose
- Add BlockHound to your CI pipeline immediately โ blocking in a reactive chain is the #1 production bug class
- Invest in structured logging with correlation IDs; debugging reactive stack traces without them is painful
- Not all services need to be reactive โ apply it where concurrency and I/O pressure are real problems