QuickCart calls three outside services for every order: payment, shipping quote, and fraud check. Each call takes 200 to 500 milliseconds. Priya’s old code called them one after another. The customer waited 1.5 seconds. She switched to parallel threads and cut that down. But each waiting thread still sits idle, doing nothing useful while the network responds.
Reactive programming is a different mindset: start the work, move on, and react when the result arrives. You do not block a thread staring at the network.
Blocking vs non-blocking
Blocking — the thread stops and waits until the answer comes back.
String paymentResult = paymentClient.charge(orderId); // thread waits here
String shippingRate = shippingClient.quote(orderId); // waits again
String fraudScore = fraudClient.check(orderId); // waits again
finishOrder(paymentResult, shippingRate, fraudScore);
Simple to read. But three calls means three waits. With 200 threads all waiting, the server spends most of its time idle.
Non-blocking — start all three, register what to do when each finishes, and let the thread do other work.
CompletableFuture<String> payment = CompletableFuture
.supplyAsync(() -> paymentClient.charge(orderId), executor);
CompletableFuture<String> shipping = CompletableFuture
.supplyAsync(() -> shippingClient.quote(orderId), executor);
CompletableFuture<String> fraud = CompletableFuture
.supplyAsync(() -> fraudClient.check(orderId), executor);
CompletableFuture.allOf(payment, shipping, fraud)
.thenRun(() -> finishOrder(payment.join(), shipping.join(), fraud.join()));
The three calls run in parallel. The thenRun step reacts when all three are done. No thread blocks on the network in the main flow.
Blocking threads vs event-loop
In a traditional blocking server, each request gets a thread. That thread waits on every I/O call. With 500 concurrent customers, you need 500 threads mostly sitting idle.
An event-loop model is different. A small number of threads handle many requests. When an I/O call starts, the thread registers a callback and moves on to the next request. When the network responds, the runtime fires the callback on an available thread.
You do not need a special framework to learn this idea. CompletableFuture in standard Java teaches the same mindset — start work, chain reactions, do not block.
Frameworks like Quarkus use an event-loop under the hood for HTTP. The important part for Priya: do not tie up threads waiting on slow I/O when you can chain callbacks instead.
The reactive mindset in plain words
Think of it like ordering food at a mall:
- Blocking — you stand at one counter until your biryani is ready, then walk to the juice shop and wait again.
- Reactive — you place all orders, get buzzers, and sit down. When a buzzer rings, you react and pick up that item.
In code:
- Start async work (return a future or promise, not the final value).
- Chain the next step to run when data arrives (
thenApply,thenCompose). - Handle errors in the chain, not only with try-catch around a blocking call.
CompletableFuture basics for QuickCart
Create async work:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return paymentClient.charge("QC-9910");
}, ioExecutor);
Transform the result when it arrives (map):
CompletableFuture<String> withReceipt = future.thenApply(result -> {
return "Receipt: " + result;
});
Chain a second async call (flatMap — the second call also returns a future):
CompletableFuture<String> fullFlow = future.thenCompose(paymentResult -> {
return CompletableFuture.supplyAsync(
() -> shippingClient.book(paymentResult), ioExecutor);
});
Handle failure:
future.exceptionally(error -> {
log.error("Payment failed for QC-9910", error);
return "PAYMENT_FAILED";
});
Handle both success and failure in one step:
future.handle((result, error) -> {
if (error != null) {
log.error("Failed", error);
return "FAILED";
}
return result;
});
Combine two results:
CompletableFuture<String> combined = paymentFuture.thenCombine(
shippingFuture,
(pay, ship) -> pay + " + " + ship
);
Wait for all, fail if any fails:
CompletableFuture<Void> all = CompletableFuture.allOf(payment, shipping, fraud);
all.thenRun(() -> finishOrder(
payment.join(), shipping.join(), fraud.join()));
Wait for all, keep going even if some fail:
CompletableFuture<List<String>> results = CompletableFuture
.allOf(f1, f2, f3)
.thenApply(v -> List.of(f1.join(), f2.join(), f3.join()));
Add a timeout so slow APIs do not hang forever:
paymentFuture
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> {
log.warn("Payment timed out for {}", orderId);
return "TIMEOUT";
});
Retry on failure:
CompletableFuture<String> withRetry = CompletableFuture
.supplyAsync(() -> paymentClient.charge(orderId), ioExecutor)
.handle((result, error) -> {
if (error != null) {
log.warn("Retrying payment for {}", orderId);
return paymentClient.charge(orderId); // one retry
}
return result;
});
QuickCart: parallel checks before checkout
Priya rewrites checkout to run three I/O calls at once and combine them:
public CompletableFuture<OrderSummary> buildOrderSummary(String orderId) {
CompletableFuture<Boolean> paymentOk = CompletableFuture
.supplyAsync(() -> paymentClient.verify(orderId), ioPool);
CompletableFuture<Double> shippingCost = CompletableFuture
.supplyAsync(() -> shippingClient.getQuote(orderId), ioPool);
CompletableFuture<String> fraudStatus = CompletableFuture
.supplyAsync(() -> fraudClient.scan(orderId), ioPool);
return CompletableFuture.allOf(paymentOk, shippingCost, fraudStatus)
.thenApply(v -> new OrderSummary(
orderId,
paymentOk.join(),
shippingCost.join(),
fraudStatus.join()
));
}
The caller gets a CompletableFuture<OrderSummary>. They can chain more steps or block at the very edge of the app with .join() if they must — but the middle of the system stays non-blocking.
QuickCart: parallel product price lookups
Priya needs prices for ten products before showing a bundle deal. Sequential calls take 10 × 100ms = 1 second. Parallel calls take about 100ms.
public CompletableFuture<BundlePrice> calculateBundlePrice(List<String> productIds) {
List<CompletableFuture<Double>> priceFutures = productIds.stream()
.map(id -> CompletableFuture.supplyAsync(
() -> catalogClient.getPrice(id), ioPool))
.collect(Collectors.toList());
return CompletableFuture.allOf(priceFutures.toArray(new CompletableFuture[0]))
.thenApply(v -> {
double total = priceFutures.stream()
.mapToDouble(CompletableFuture::join)
.sum();
return new BundlePrice(productIds, total);
});
}
Same pattern as calling payment, shipping, and fraud in parallel — start all, react when all finish.
Uni and Mutiny — same idea, different API
Some frameworks (like Quarkus) use Mutiny and Uni. A Uni<T> is a bit like CompletableFuture<T> — it represents a result that will arrive later.
Creating a Uni:
import io.smallrye.mutiny.Uni;
// From a value
Uni<String> uni = Uni.createFrom().item("QC-9910");
// From a supplier (lazy)
Uni<String> payment = Uni.createFrom().item(() -> paymentClient.charge(orderId));
// From a CompletableFuture
CompletableFuture<String> future = CompletableFuture.supplyAsync(...);
Uni<String> fromFuture = Uni.createFrom().completionStage(future);
Chaining:
Uni<String> payment = Uni.createFrom().item(() -> paymentClient.charge(orderId));
payment
.onItem().transform(result -> "Paid: " + result) // like thenApply
.onItem().transformToUni(result -> // like thenCompose
Uni.createFrom().item(() -> shippingClient.book(result)))
.onFailure().recoverWithItem("FAILED") // like exceptionally
.ifNoItem().after(Duration.ofSeconds(5)) // like orTimeout
.failWith(new TimeoutException("Payment timed out"))
.subscribe().with(
value -> log.info("Done: {}", value), // like thenAccept
error -> log.error("Error", error)
);
Combine multiple Unis:
Uni<String> payment = Uni.createFrom().item(() -> paymentClient.charge(orderId));
Uni<String> shipping = Uni.createFrom().item(() -> shippingClient.quote(orderId));
Uni<String> combined = Uni.combine().all()
.unis(payment, shipping)
.combinedWith((pay, ship) -> pay + " + " + ship);
// like thenCombine
// Wait for all (like allOf)
Uni.join().all(List.of(payment, shipping, fraud))
.andCollectFailures()
.subscribe().with(
results -> finishOrder(results),
error -> log.error("One or more checks failed", error)
);
You do not need to master Mutiny to understand reactive programming. The ideas are the same:
| Mutiny / Uni | CompletableFuture |
|---|---|
Uni.createFrom().item() |
CompletableFuture.supplyAsync() |
.onItem().transform() |
.thenApply() |
.onItem().transformToUni() |
.thenCompose() |
.onFailure().recoverWithItem() |
.exceptionally() |
.ifNoItem().after(duration) |
.orTimeout(duration) |
Uni.combine().all() |
.thenCombine() / allOf() |
.subscribe().with() |
.thenAccept() / callbacks |
If your team uses Quarkus and Mutiny, learn its syntax. If not, CompletableFuture teaches the mindset perfectly well.
Backpressure
When producers send data faster than consumers can handle it, something has to give. Backpressure is the mechanism that slows producers down so consumers are not overwhelmed.
In QuickCart’s kitchen-to-counter queue, a full BlockingQueue creates backpressure — the kitchen blocks on put() until the counter catches up. In reactive streams, backpressure is built into the protocol: the consumer tells the producer how many items it can accept.
For most QuickCart services, a bounded queue or a thread pool with a bounded task queue gives you enough backpressure. Full reactive backpressure matters most in high-throughput streaming systems — live order event feeds, analytics pipelines — where data never stops arriving.
When reactive helps — and when it does not
Good for:
- Many I/O-bound calls (HTTP, database, message queues).
- Composing many small async steps in a pipeline.
- Serving more concurrent users with fewer blocked threads.
Not worth it for:
- Simple CPU-bound math on a small list — just use a loop or parallel stream.
- Code that is naturally sequential and fast already.
- Teams with no observability — async stack traces are harder to debug.
Priya uses reactive-style async for checkout and payment. She keeps inventory counting as plain synchronous code — it is fast and local.
Rules for readable async code
- Use a named executor for I/O work — do not rely on the default common pool for blocking HTTP calls.
ExecutorService ioPool = Executors.newFixedThreadPool(20);
// use ioPool in every supplyAsync call
- Handle errors in the chain —
.exceptionally()or.handle(). - Propagate MDC into async steps (see the previous chapter).
- Avoid blocking in the middle of a chain —
.join()and.get()belong at the edges, not deep inside services. - Add timeouts —
orTimeout()so slow APIs do not hang forever.
// BAD — blocks inside a service method
public OrderSummary getSummary(String orderId) {
return buildOrderSummary(orderId).join(); // blocks the calling thread
}
// GOOD — return the future, let the caller decide when to wait
public CompletableFuture<OrderSummary> getSummary(String orderId) {
return buildOrderSummary(orderId);
}
What to remember
- Reactive means react to results as they arrive — do not block threads while waiting on I/O.
- Blocking code is easy to read but ties up threads during network waits.
CompletableFutureis the standard Java way to chain async steps, combine results, handle errors, and set timeouts.- Uni/Mutiny follow the same ideas with different method names — learn the mindset first.
- Backpressure slows fast producers so slow consumers are not overwhelmed — bounded queues do this in practice.
- Use reactive patterns for I/O-heavy flows like QuickCart checkout, not for every line of code.
What Priya does next: she sits down with a friend and practices explaining everything she learned — threads, pools, locks, deadlocks, and all the rest — out loud, like a real interview.