Mastering Multiple WebClient Calls in Spring: Combine & Optimize
Learn to make and combine multiple simultaneous WebClient calls in Spring Boot with reactive patterns, error handling, and production-tested optimization strategies..
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Java 17+
- ✓Spring Boot 3.x with WebFlux (spring-boot-starter-webflux)
- ✓Basic understanding of Project Reactor (Mono, Flux)
- ✓Familiarity with Spring WebClient
- Use
Flux.zip()orMono.zip()to combine multiple WebClient calls concurrently. - Prefer
flatMap()over blockingblock()for non-blocking composition. - Handle partial failures with
onErrorResume()orretryWhen(). - Use
WebClient.builder()with timeouts and connection pooling for production. - Avoid
block()in reactive pipelines; it defeats the purpose of non-blocking I/O.
Imagine you're ordering a pizza and a salad from two different restaurants. Instead of calling one, waiting, then calling the other, you call both at the same time and wait for both deliveries. That's what combining multiple WebClient calls does—it fires off requests in parallel and combines results as they arrive, making your app faster and more efficient.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
In modern microservices architectures, your application often needs to fetch data from multiple upstream services to build a response. Doing this sequentially—calling Service A, waiting, then calling Service B—adds latency linearly. For a user-facing API, that's a death sentence. I've seen production outages where a chain of five sequential calls caused a 5-second response time, leading to timeouts and angry customers.
Spring WebClient, the reactive HTTP client introduced in Spring 5, lets you make multiple calls simultaneously and combine their results without blocking threads. But doing it right is harder than it looks. I've debugged countless incidents where developers used inside reactive pipelines, turning a non-blocking flow into a thread-pool disaster.block()
In this guide, I'll show you the battle-tested patterns for making and combining multiple WebClient calls. We'll cover reactive composition with , error handling for partial failures, production configuration, and the gotchas that the official docs gloss over. By the end, you'll be able to build fast, resilient services that aggregate data from multiple sources without breaking a sweat.zip()
Why Parallelism Matters: The Latency Tax
Every network call adds latency. If you make three sequential HTTP calls, each taking 100ms, your total response time is 300ms plus overhead. In a microservices world, that tax multiplies. I've seen services that called five upstream APIs sequentially, resulting in a 1.5-second response time for a simple dashboard. The fix? Parallelize with WebClient.
Spring WebClient is built on Project Reactor, which gives you non-blocking I/O out of the box. Instead of waiting for each response, you can fire all requests at once and combine them when they're ready. The key operators are Mono.zip() for combining multiple Mono results, and Flux.zip() for combining streams.
Let's start with a naive sequential approach that you should never use in production:
zip() to reduce total latency to the slowest call, not the sum.The Right Way: Parallel Composition with zip()
When calls are independent, use Mono.zip() to fire them concurrently. Here's the correct pattern:
zip() indefinitely. Use .timeout(Duration.ofSeconds(2)) on each Mono.Mono.zip() for independent calls. It fires all requests simultaneously and combines results when all are ready.Handling Partial Failures Gracefully
In the real world, upstream services fail. If you use plain , a single failure causes the whole combined Mono to fail. That's often too harsh. Instead, you want to gracefully degrade: if the user service fails, still return the order data with a null user, or a default.zip()
The pattern is to use onErrorResume() per call to provide a fallback:
onErrorResume() per call to isolate failures and provide fallback values, preventing a single upstream failure from breaking the whole response.What the Official Docs Won't Tell You
The Spring docs show you how to use , but they don't warn you about these production pitfalls:zip()
block()is a silent killer: The docs mentionblock()for testing, but developers use it in controllers. It blocks the event loop thread, causing thread starvation. I've debugged a production incident where a singleblock()call brought down a payment service. Never useblock()in a reactive pipeline.- Connection pooling defaults are too low: The default
maxConnectionsis 500, but thependingAcquireTimeoutis 60 seconds. Under load, you'll see "Connection pool exhausted" errors. IncreasemaxConnectionsand reduce the acquire timeout. doesn't cancel on error: If one Mono fails, the others continue running. This wastes resources. Usezip()zipWhen()oronErrorMap()to cancel remaining calls if you don't need partial results.- Timeouts are not transitive: A timeout on a single call doesn't propagate to the
. If you want the whole operation to timeout, wrap thezip()in azip().timeout()as well. - Memory leaks from unread bodies: If you don't consume the response body (e.g., you only check status), the connection is not released. Always read the body or use
exchangeToMono()with proper cleanup.
block(), tune connection pools, handle partial failures, and always set timeouts at both the individual call and combined operation level.Combining Multiple Calls with Flux for Lists
Sometimes you need to make multiple calls based on a list of items. For example, fetch details for each user ID in a list. You can use Flux.flatMap() to fire requests concurrently, but be careful about concurrency limits:
Flux.flatMap() with a concurrency limit to parallelize calls for a list of items. Handle errors per item with onErrorResume().Advanced: Conditional Composition with zipWhen
Sometimes you need to make a second call only if the first succeeds. For example, fetch user details, then fetch their orders only if the user is active. zipWhen() is perfect for this:
zipWhen() for conditional calls where the second call depends on the result of the first, but still want both results combined.The Blocking Disaster at a Fintech Startup
Mono.block().block() after zip() was safe because the combined Mono was already resolved.block() caused the reactive pipeline to block the Netty event loop thread, leading to thread starvation and cascading timeouts across all endpoints.block() with Mono.zip().flatMap() and used subscribe() at the controller level. Added a dedicated Scheduler for blocking operations where unavoidable.- Never call
in a reactive pipeline unless you're at the very edge of your application (e.g., a main method).block() - Use
flatMap()instead ofto transform results asynchronously.block() - Always configure timeouts on WebClient to prevent thread exhaustion from slow upstream services.
- Monitor thread pools and event loop groups in production to spot blocking early.
- When you must block (e.g., in a legacy servlet stack), use a bounded, dedicated scheduler.
zip() to parallelize. Profile with a tool like Zipkin to visualize call chains.block() calls in reactive code. Replace with reactive operators. Ensure you're not blocking the event loop.onErrorResume() to return fallback values or partial data. Use zip() with delayUntil to handle errors gracefully.maxInMemorySize() on WebClient. Ensure backpressure is applied via limitRate() or similar.maxConnections and pendingAcquireTimeout in the connection provider. Verify that responses are consumed fully (e.g., body is read).Add logging around each WebClient call with timestampsUse `zip()` to parallelizeflatMap chain with Mono.zip()| File | Command / Code | Purpose |
|---|---|---|
| SequentialCalls.java | Mono | Why Parallelism Matters |
| ParallelCallsZip.java | Mono | The Right Way |
| ErrorHandlingFallback.java | Mono | Handling Partial Failures Gracefully |
| ProductionWebClientConfig.java | @Bean | What the Official Docs Won't Tell You |
| FluxParallelCalls.java | List | Combining Multiple Calls with Flux for Lists |
| ConditionalZipWhen.java | Mono | Advanced |
Key takeaways
Mono.zip() or Flux.flatMap() to reduce latency.onErrorResume() per call with fallback values.block() in reactive pipelines; use reactive operators and proper error handling.zipWhen() for conditional composition where the second call depends on the first.Interview Questions on This Topic
How would you make three independent HTTP calls in parallel using Spring WebClient and combine their results?
Mono.zip(): create three separate Monos from WebClient calls, then Mono.zip(mono1, mono2, mono3).map(tuple -> new Response(tuple.getT1(), tuple.getT2(), tuple.getT3())). This fires all calls concurrently and combines results when all are ready.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Spring Boot. Mark it forged?
3 min read · try the examples if you haven't