Home Java Mastering Multiple WebClient Calls in Spring: Combine & Optimize
Intermediate 3 min · July 14, 2026

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..

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Java 17+
  • Spring Boot 3.x with WebFlux (spring-boot-starter-webflux)
  • Basic understanding of Project Reactor (Mono, Flux)
  • Familiarity with Spring WebClient
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use Flux.zip() or Mono.zip() to combine multiple WebClient calls concurrently.
  • Prefer flatMap() over blocking block() for non-blocking composition.
  • Handle partial failures with onErrorResume() or retryWhen().
  • Use WebClient.builder() with timeouts and connection pooling for production.
  • Avoid block() in reactive pipelines; it defeats the purpose of non-blocking I/O.
✦ Definition~90s read
What is Making and Combining Multiple Simultaneous WebClient Calls in Spring?

Making and combining multiple simultaneous WebClient calls in Spring means using reactive operators like Mono.zip() to fire several HTTP requests concurrently and merge their results into a single response without blocking threads.

Imagine you're ordering a pizza and a salad from two different restaurants.
Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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 block() inside reactive pipelines, turning a non-blocking flow into a thread-pool disaster.

In this guide, I'll show you the battle-tested patterns for making and combining multiple WebClient calls. We'll cover reactive composition with zip(), 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.

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:

SequentialCalls.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// BAD: Sequential calls - adds latency
Mono<User> userMono = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(User.class);

Mono<Order> orderMono = userMono.flatMap(user ->
    webClient.get()
        .uri("/orders?userId={id}", user.getId())
        .retrieve()
        .bodyToMono(Order.class)
);

// Total time: user call + order call = 200ms if each is 100ms
⚠ Sequential is a Performance Trap
🎯 Key Takeaway
Parallelize independent calls with 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:

ParallelCallsZip.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// GOOD: Parallel calls using Mono.zip
Mono<User> userMono = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(User.class);

Mono<Order> orderMono = webClient.get()
    .uri("/orders?userId={id}", userId)
    .retrieve()
    .bodyToMono(Order.class);

Mono<DashboardResponse> dashboard = Mono.zip(
    userMono, orderMono
).map(tuple -> {
    User user = tuple.getT1();
    Order order = tuple.getT2();
    return new DashboardResponse(user, order);
});

// Total time: max(100ms, 100ms) = 100ms (plus overhead)
💡zip() Waits for All Results
📊 Production Insight
In production, always set timeouts on individual WebClient calls. A slow upstream can hold up the entire zip() indefinitely. Use .timeout(Duration.ofSeconds(2)) on each Mono.
🎯 Key Takeaway
Use 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 zip(), 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.

The pattern is to use onErrorResume() per call to provide a fallback:

ErrorHandlingFallback.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Mono<User> userMono = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(User.class)
    .onErrorResume(e -> {
        log.warn("Failed to fetch user, using default", e);
        return Mono.just(User.DEFAULT);
    })
    .timeout(Duration.ofSeconds(2));

Mono<Order> orderMono = webClient.get()
    .uri("/orders?userId={id}", userId)
    .retrieve()
    .bodyToMono(Order.class)
    .onErrorResume(e -> Mono.just(Order.EMPTY))
    .timeout(Duration.ofSeconds(3));

Mono<DashboardResponse> dashboard = Mono.zip(
    userMono, orderMono
).map(tuple -> new DashboardResponse(tuple.getT1(), tuple.getT2()));
🔥Fallback Values Are Your Friend
📊 Production Insight
I once saw a cascading failure where the user service was slow, causing timeouts on the order service because the combined timeout was too short. Always set individual timeouts that are shorter than the overall timeout.
🎯 Key Takeaway
Use 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 zip(), but they don't warn you about these production pitfalls:

  1. block() is a silent killer: The docs mention block() 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 single block() call brought down a payment service. Never use block() in a reactive pipeline.
  2. Connection pooling defaults are too low: The default maxConnections is 500, but the pendingAcquireTimeout is 60 seconds. Under load, you'll see "Connection pool exhausted" errors. Increase maxConnections and reduce the acquire timeout.
  3. zip() doesn't cancel on error: If one Mono fails, the others continue running. This wastes resources. Use zipWhen() or onErrorMap() to cancel remaining calls if you don't need partial results.
  4. Timeouts are not transitive: A timeout on a single call doesn't propagate to the zip(). If you want the whole operation to timeout, wrap the zip() in a .timeout() as well.
  5. 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.
ProductionWebClientConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public WebClient webClient() {
    ConnectionProvider provider = ConnectionProvider.builder("custom")
        .maxConnections(1000)
        .pendingAcquireTimeout(Duration.ofSeconds(30))
        .maxIdleTime(Duration.ofSeconds(20))
        .build();

    HttpClient httpClient = HttpClient.create(provider)
        .responseTimeout(Duration.ofSeconds(5));

    return WebClient.builder()
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .build();
}
⚠ Configure Connection Pool for Production
🎯 Key Takeaway
Avoid 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:

FluxParallelCalls.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
List<String> userIds = List.of("1", "2", "3");

Flux<User> users = Flux.fromIterable(userIds)
    .flatMap(id -> webClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .bodyToMono(User.class)
        .onErrorResume(e -> Mono.empty()) // skip failed
        .timeout(Duration.ofSeconds(2)),
        5 // concurrency limit: max 5 parallel calls
    );

// Collect results
Mono<List<User>> userList = users.collectList();
💡Set Concurrency Limit
📊 Production Insight
In production, monitor the downstream service's capacity. A sudden burst of parallel calls can trigger rate limiting or degrade performance. Use circuit breakers (Resilience4j) for critical paths.
🎯 Key Takeaway
Use 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:

ConditionalZipWhen.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Mono<DashboardResponse> dashboard = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(User.class)
    .zipWhen(user -> {
        if (user.isActive()) {
            return webClient.get()
                .uri("/orders?userId={id}", user.getId())
                .retrieve()
                .bodyToMono(Order.class);
        } else {
            return Mono.just(Order.EMPTY);
        }
    })
    .map(tuple -> new DashboardResponse(tuple.getT1(), tuple.getT2()));
🔥zipWhen vs flatMap
🎯 Key Takeaway
Use zipWhen() for conditional calls where the second call depends on the result of the first, but still want both results combined.
● Production incidentPOST-MORTEMseverity: high

The Blocking Disaster at a Fintech Startup

Symptom
The service returned responses in 10+ seconds under moderate load, and thread dumps showed hundreds of threads blocked on Mono.block().
Assumption
The developer assumed that calling block() after zip() was safe because the combined Mono was already resolved.
Root cause
block() caused the reactive pipeline to block the Netty event loop thread, leading to thread starvation and cascading timeouts across all endpoints.
Fix
Replaced block() with Mono.zip().flatMap() and used subscribe() at the controller level. Added a dedicated Scheduler for blocking operations where unavoidable.
Key lesson
  • Never call block() in a reactive pipeline unless you're at the very edge of your application (e.g., a main method).
  • Use flatMap() instead of block() to transform results asynchronously.
  • 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.
Production debug guideSymptom to Action5 entries
Symptom · 01
Response times are high (multiple seconds)
Fix
Check if you're making calls sequentially. Use zip() to parallelize. Profile with a tool like Zipkin to visualize call chains.
Symptom · 02
Thread dumps show many threads in BLOCKED state
Fix
Search for block() calls in reactive code. Replace with reactive operators. Ensure you're not blocking the event loop.
Symptom · 03
Some upstream calls fail, but the whole response fails
Fix
Implement per-call error handling with onErrorResume() to return fallback values or partial data. Use zip() with delayUntil to handle errors gracefully.
Symptom · 04
Memory grows unbounded under load
Fix
Check if you're using unbounded buffers. Configure maxInMemorySize() on WebClient. Ensure backpressure is applied via limitRate() or similar.
Symptom · 05
Connection pool exhaustion errors
Fix
Increase maxConnections and pendingAcquireTimeout in the connection provider. Verify that responses are consumed fully (e.g., body is read).
★ Quick Debug Cheat SheetImmediate actions for common issues with multiple WebClient calls.
High latency
Immediate action
Check if calls are sequential
Commands
Add logging around each WebClient call with timestamps
Use `zip()` to parallelize
Fix now
Replace flatMap chain with Mono.zip()
Thread starvation+
Immediate action
Search for `block()` in codebase
Commands
grep -r "\.block()" src/
Check thread dumps for event loop threads
Fix now
Remove block() and use reactive operators
Partial failures+
Immediate action
Add error handling per call
Commands
Wrap each call with `onErrorResume()`
Use `zip()` with delayUntil to isolate errors
Fix now
Return fallback Mono on error
Connection pool exhaustion+
Immediate action
Check connection pool metrics
Commands
Actuator: /actuator/metrics/http.client.connections
Check pending acquire timeout
Fix now
Increase maxConnections and pendingAcquireTimeout
ApproachParallelismError HandlingUse Case
Sequential flatMapNo (sequential)Per call with onErrorResumeDependent calls
Mono.zip()Yes (parallel)Fails all on any errorIndependent calls, all must succeed
Mono.zip() with fallbacksYes (parallel)Graceful per callIndependent calls, tolerant of failures
Flux.flatMap()Yes (parallel, configurable concurrency)Per item with onErrorResumeList of items, e.g., batch processing
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
SequentialCalls.javaMono userMono = webClient.get()Why Parallelism Matters
ParallelCallsZip.javaMono userMono = webClient.get()The Right Way
ErrorHandlingFallback.javaMono userMono = webClient.get()Handling Partial Failures Gracefully
ProductionWebClientConfig.java@BeanWhat the Official Docs Won't Tell You
FluxParallelCalls.javaList userIds = List.of("1", "2", "3");Combining Multiple Calls with Flux for Lists
ConditionalZipWhen.javaMono dashboard = webClient.get()Advanced

Key takeaways

1
Parallelize independent WebClient calls with Mono.zip() or Flux.flatMap() to reduce latency.
2
Handle partial failures gracefully using onErrorResume() per call with fallback values.
3
Never use block() in reactive pipelines; use reactive operators and proper error handling.
4
Configure WebClient with appropriate timeouts, connection pool settings, and error handling for production.
5
Use zipWhen() for conditional composition where the second call depends on the first.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How would you make three independent HTTP calls in parallel using Spring...
Q02SENIOR
What happens if one of the calls in a `zip()` fails? How do you handle i...
Q03SENIOR
Explain how to prevent thread starvation when using WebClient in a Sprin...
Q01 of 03SENIOR

How would you make three independent HTTP calls in parallel using Spring WebClient and combine their results?

ANSWER
Use 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.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What's the difference between `zip()` and `flatMap()` for combining WebClient calls?
02
How do I set a timeout for the entire combined operation?
03
Can I use WebClient with synchronous (blocking) code?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

3 min read · try the examples if you haven't

Previous
Spring WebClient and OAuth2 Support: Client Credentials and Authorization Code Flow
119 / 121 · Spring Boot
Next
Spring WebClient vs RestTemplate: When to Use Each in Modern Spring Applications