Definition
- Check independence – if downstream calls do not depend on each other's results, they can run in parallel.
- Fan-out – use
CompletableFuture.allOf()on a dedicated executor; latency = slowest call, not sum. - Timeout –
orTimeout()per call caps latency and prevents thread hang. - Fallback –
exceptionally()provides cached or default values when a call fails. - Dedicated pool – never use commonPool for blocking downstream calls.
- Safeguard checklist – timeout, fallback, dedicated pool, connection pool sizing, circuit breaker, cache, retry.
A customer at National Bank of India waits for a loan decision. The teller calls the credit bureau (10s), waits, calls property valuation (12s), waits, calls income verification (8s). Total: 30 seconds. Fix: the teller calls all three simultaneously using three phone lines. Waits for the slowest (12s). Total: 12 seconds. But the teller must ensure phone lines are available (connection pool), set a timer per call (timeout), and have backup numbers (circuit breaker, cache).
Understanding the Concepts
What does the sequential version look like?
package com.bank.loan;
import java.math.BigDecimal;
public class LoanApprovalController {
@GetMapping("/loan-approval/{customerId}")
public LoanDecision approveLoan(@PathVariable String customerId) {
CreditScore score = creditService.getScore(customerId); // 10s
PropertyValue property = propertyService.getValue(customerId); // 12s
IncomeProof income = incomeService.verify(customerId); // 8s
return decide(score, property, income);
// Total: 10 + 12 + 8 = 30 seconds!
}
}
The three calls are independent: none depends on the result of another. This means they can run in parallel.
What does the parallel version look like?
package com.bank.loan;
import java.util.concurrent.*;
public class LoanApprovalController {
private final ExecutorService ioPool = Executors.newFixedThreadPool(30);
@GetMapping("/loan-approval/{customerId}")
public LoanDecision approveLoan(@PathVariable String customerId) {
CompletableFuture<CreditScore> scoreCF = CompletableFuture
.supplyAsync(() -> creditService.getScore(customerId), ioPool)
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> cachedScoreService.get(customerId));
CompletableFuture<PropertyValue> propertyCF = CompletableFuture
.supplyAsync(() -> propertyService.getValue(customerId), ioPool)
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> PropertyValue.UNKNOWN);
CompletableFuture<IncomeProof> incomeCF = CompletableFuture
.supplyAsync(() -> incomeService.verify(customerId), ioPool)
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> IncomeProof.PENDING);
CompletableFuture.allOf(scoreCF, propertyCF, incomeCF).join();
return decide(scoreCF.join(), propertyCF.join(), incomeCF.join());
// Total: max(5, 5, 5) = 5s max with timeout cap!
}
}
Why use a dedicated IO pool instead of commonPool?
commonPool has only cores - 1 threads and is shared across the entire JVM. Blocking IO (HTTP calls to external services) on commonPool would starve parallel streams and other CompletableFutures. A dedicated IO pool with 30 threads ensures the blocking calls do not affect other parts of the application.
What is the safeguard checklist?
| Safeguard | Implementation | Why | |---|---|---| | Timeout per call | orTimeout(5, SECONDS) | Caps maximum wait | | Fallback | exceptionally(ex -> cached/default) | Always returns a response | | Dedicated pool | ioPool (not commonPool) | Prevents resource starvation | | Connection pool | RestTemplate/WebClient pool sized for load | Handles concurrent connections | | Circuit breaker | Resilience4j per downstream | Fast-fails on unhealthy services | | Cache | Frequently-requested data cached (Redis, 5min TTL) | Reduces downstream load | | Retry | Max 2 retries with exponential backoff | Handles transient failures | | Bulkhead | Bounded pool per downstream service | Limits blast radius |
What is the performance improvement?
| Version | Latency | Notes |
|---|---|---|
| Sequential | 30s | 10 + 12 + 8 |
| Parallel (no timeout) | 12s | max(10, 12, 8) |
| Parallel + 5s timeout | 5s max | Timeout cap |
| Parallel + fallback | 5s max, always succeeds | Cached on failure |
What if one call depends on another's result?
That pair must remain sequential using thenCompose, but independent calls can still be parallel.
// Profile must be fetched before score (score needs profile ID)
// But income verification is independent
CompletableFuture<CreditScore> scoreCF = CompletableFuture
.supplyAsync(() -> profileService.getProfile(customerId), ioPool)
.thenCompose(profile -> CompletableFuture
.supplyAsync(() -> creditService.getScore(profile.getId()), ioPool));
CompletableFuture<IncomeProof> incomeCF = CompletableFuture
.supplyAsync(() -> incomeService.verify(customerId), ioPool);
// Both run in parallel: scoreCF (sequential internally) and incomeCF
CompletableFuture.allOf(scoreCF, incomeCF).join();
How do you size the connection pool?
Based on maximum concurrent downstream calls: incoming_rate x fan_out x avg_latency. If 100 requests per second each make 3 downstream calls averaging 200ms, you need at least 100 x 3 x 0.2 = 60 concurrent connections. Add 50% headroom for spikes.
Should you always parallelize?
No. Only when calls are independent and latency improvement justifies the added complexity. If the sequential version completes in 50ms, parallelizing adds complexity for negligible gain. Parallelize when the sequential sum is noticeably painful for the user.
What are common interview follow-up questions?
- How do you handle partial failures? The exceptionally() fallback ensures each leg always returns a value. The decide() method can handle UNKNOWN or PENDING values with reduced confidence.
- How do you monitor parallel calls? Log the elapsed time of each leg and the overall request. Alert when any leg's P95 approaches the timeout threshold.
- How do virtual threads change this? Virtual threads make the IO pool sizing moot, but you still need timeouts and circuit breakers.
Visual Overview
Sequential: 30 seconds
┌──────────┐ ┌────────────┐ ┌────────┐
│ Credit │ │ Property │ │ Income │
│ 10s │ │ 12s │ │ 8s │
└──────────┘ └────────────┘ └────────┘
|---10s---||---12s---||---8s---| = 30s
Parallel with allOf: 12 seconds
┌──────────┐
│ Credit │ 10s
├────────────┐
│ Property │ 12s
├────────┐ │
│ Income │ 8s│
└────────┘ │
▼
allOf().join() = 12s (slowest)
Parallel + timeout: 5 seconds max
┌──────────┐
│ Credit │ orTimeout(5s) → cached score
├──────────┐
│ Property │ orTimeout(5s) → UNKNOWN
├──────────┐
│ Income │ orTimeout(5s) → PENDING
└──────────┘
Max: 5s, always succeeds with real or fallback
Key Takeaway
Optimizing a 30-second REST endpoint starts with checking if the three downstream calls are independent. If they do not need each other's results, fan out with allOf() on a dedicated IO pool. Latency drops from the sum (30s) to the slowest call (12s). Adding 5-second timeouts with orTimeout() caps the maximum at 5 seconds. Adding fallbacks with exceptionally() ensures the endpoint always responds, even when a dependency is down.
The safeguards are non-optional. Parallelism without them creates new resource problems: connection pool exhaustion, downstream overload, and cascading failures. Size connection pools for the amplified load. Add circuit breakers per downstream. Cache frequently-requested data.
At National Bank of India, the loan approval API went from 30 seconds (70% customer drop-off) to 5 seconds max (15% drop-off). Same three calls, dramatically better user experience, with graceful degradation when any downstream is slow.
Remember one sentence: check independence, fan out with allOf, add timeout and fallback per call, and use a dedicated pool.
---