โ† All Articles

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.

The Core Problem

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:

Results

After migrating the core cart read path and checkout initiation flow to WebFlux + reactive Kafka:

Key Takeaways