The Problem: Blocking I/O Doesn’t Scale
When I joined the GIFT City Mutual Fund platform project, we had a clear requirement: process 10,000+ concurrent investor onboarding requests with sub-300ms latency at the gateway level. Our system needed to orchestrate multiple external API calls:
- PAN verification via CAMS (2-3 seconds)
- CKYC lookup via CERSAI (1-2 seconds)
- AML screening (500ms)
- Bank verification via DSP GR OAuth2 API (1 second)
- Database queries across AlloyDB (50-100ms each)
With traditional blocking I/O (Spring MVC + JDBC), each request thread would be blocked waiting for I/O operations. At 10,000 concurrent users, we’d need 10,000 threads—consuming ~1GB of memory just for thread stacks. This doesn’t scale.
Enter Spring WebFlux: Non-Blocking All the Way Down
Spring WebFlux, built on Project Reactor, uses an event-loop model (similar to Node.js) where a small pool of threads handles thousands of concurrent connections. Instead of blocking, operations return Mono<T> or Flux<T> that emit results when ready.
Here’s how we transformed our onboarding flow:
Before (Blocking - Spring MVC + JDBC)
@RestController
@RequestMapping("/api/onboarding")
public class OnboardingController {
@Autowired
private PanVerificationService panService;
@Autowired
private CkycService ckycService;
@Autowired
private ApplicationRepository applicationRepo;
@PostMapping("/verify-kyc")
public ResponseEntity<KycResponse> verifyKyc(@RequestBody KycRequest request) {
// ❌ BLOCKING: Thread waits here (2-3 seconds)
PanResponse panResponse = panService.verifyPan(request.getPan());
// ❌ BLOCKING: Thread waits here (1-2 seconds)
CkycResponse ckycResponse = ckycService.searchCkyc(request.getCkycId());
// ❌ BLOCKING: Thread waits here (50-100ms)
Application application = applicationRepo.save(
new Application(panResponse, ckycResponse)
);
return ResponseEntity.ok(new KycResponse(application));
}
}
Problem:
- Each request holds a thread for ~4-5 seconds
- With 200 threads (typical Tomcat config), we max out at 40 concurrent requests/second
- Under 10,000 concurrent users, requests queue up, latency spikes to 30+ seconds
After (Non-Blocking - Spring WebFlux + R2DBC)
@RestController
@RequestMapping("/api/onboarding")
public class OnboardingController {
@Autowired
private PanVerificationService panService;
@Autowired
private CkycService ckycService;
@Autowired
private ApplicationRepository applicationRepo;
@PostMapping("/verify-kyc")
public Mono<ResponseEntity<KycResponse>> verifyKyc(@RequestBody KycRequest request) {
return Mono.zip(
// ✅ NON-BLOCKING: Returns immediately with Mono<PanResponse>
panService.verifyPanReactive(request.getPan()),
// ✅ NON-BLOCKING: Runs in parallel while PAN is being verified
ckycService.searchCkycReactive(request.getCkycId())
)
.flatMap(tuple -> {
PanResponse panResponse = tuple.getT1();
CkycResponse ckycResponse = tuple.getT2();
// ✅ NON-BLOCKING: R2DBC reactive database driver
return applicationRepo.save(new Application(panResponse, ckycResponse));
})
.map(application -> ResponseEntity.ok(new KycResponse(application)));
}
}
Benefits:
- Parallel execution: PAN and CKYC calls happen simultaneously (saves 1-2 seconds)
- Non-blocking I/O: Event loop threads never block, handling thousands of requests with just 8-16 threads
- Result: P95 latency dropped from 5+ seconds to <300ms
When to Use WebFlux (and When NOT To)
✅ Use Spring WebFlux When:
-
High concurrency with I/O-bound operations
- Our case: 10,000+ concurrent requests, each making 4-5 external API calls
- Reactive shines when threads would otherwise be blocked waiting for I/O
-
Streaming large datasets
- Example: Exporting 1M+ transaction records as CSV
@GetMapping(value = "/transactions/export", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<Transaction> exportTransactions() { return transactionRepo.findAll() // Returns Flux<Transaction> .buffer(1000) // Process in batches to avoid memory overflow .flatMap(Flux::fromIterable); } -
Backpressure handling
- When upstream produces data faster than downstream can consume
- Reactive Streams specification has built-in backpressure support
-
Reactive all the way
- Database: Using R2DBC (not JDBC)
- HTTP clients: Using WebClient (not RestTemplate)
- Message queues: Reactive Kafka, R2DBC Pub/Sub
- If any layer is blocking, you lose the benefits
❌ Don’t Use Spring WebFlux When:
-
CPU-bound operations
// ❌ BAD: Blocking CPU-intensive work on event loop thread public Mono<Report> generateReport() { return Mono.fromCallable(() -> { // This blocks the event loop thread! return expensiveComputationTakingSeconds(); }); } // ✅ GOOD: Offload to separate thread pool public Mono<Report> generateReport() { return Mono.fromCallable(() -> expensiveComputationTakingSeconds()) .subscribeOn(Schedulers.boundedElastic()); // Runs on dedicated thread pool } -
JDBC databases
- JDBC is blocking by design
- Calling
jdbcTemplate.query()in aMono.fromCallable()defeats the purpose - You MUST use R2DBC drivers (PostgreSQL, MySQL, H2, SQL Server support)
-
Team lacks reactive experience
- Reactive programming has a steep learning curve
- Debugging stack traces is harder (
reactor.core.publisher.FluxFlatMap$FlatMapMain) - Error handling is different (
onErrorResume,onErrorReturnvs try-catch)
-
Simple CRUD applications
- If your app has low concurrency (< 100 req/s) and simple database queries, Spring MVC is simpler
- Don’t add complexity without clear performance gains
Real-World Results: GIFT City Platform
After migrating from Spring MVC + JDBC to WebFlux + R2DBC:
| Metric | Before (Blocking) | After (Reactive) | Improvement |
|---|---|---|---|
| P95 Latency | 5.2 seconds | 287ms | 94% reduction |
| Throughput | 40 req/s (200 threads) | 10,000+ req/s (8 threads) | 250x increase |
| Database Latency | 120ms avg | 45ms avg | 60% reduction |
| Memory Usage | 1.2GB (thread stacks) | 400MB | 67% reduction |
| Thread Count | 200 (Tomcat) | 8 (Event Loop) | 96% reduction |
Key Takeaway: We handled 10,000+ concurrent requests with P95 latency under 300ms using just 8 threads on Google Kubernetes Engine with horizontal pod autoscaling.
Gotchas & Lessons Learned
1. Never Block the Event Loop
// ❌ DISASTER: Blocks event loop thread
public Mono<User> getUserFromLegacyService(String userId) {
return Mono.fromCallable(() -> legacyBlockingClient.getUser(userId)); // BLOCKING CALL!
}
// ✅ CORRECT: Offload blocking work
public Mono<User> getUserFromLegacyService(String userId) {
return Mono.fromCallable(() -> legacyBlockingClient.getUser(userId))
.subscribeOn(Schedulers.boundedElastic()); // Runs on separate thread pool
}
Why: Event loop threads are precious (only 8-16 of them). Blocking one starves all other requests.
2. Use Mono.zip() for Parallel API Calls
// ❌ SEQUENTIAL: Takes 5 seconds total (2s + 1s + 2s)
public Mono<CompleteData> fetchData() {
return panService.verify(pan) // 2 seconds
.flatMap(panData -> ckycService.search()) // 1 second
.flatMap(ckycData -> amlService.screen()); // 2 seconds
}
// ✅ PARALLEL: Takes 2 seconds total (max of all)
public Mono<CompleteData> fetchData() {
return Mono.zip(
panService.verify(pan), // 2 seconds
ckycService.search(), // 1 second (runs in parallel)
amlService.screen() // 2 seconds (runs in parallel)
).map(tuple -> new CompleteData(tuple.getT1(), tuple.getT2(), tuple.getT3()));
}
Improvement: Reduced onboarding step 2 from 5 seconds → 2 seconds (60% faster).
3. R2DBC Connection Pooling is Critical
// application.yml
spring:
r2dbc:
url: r2dbc:postgresql://alloydb-instance:5432/giftcity
pool:
initial-size: 10
max-size: 50 # Match your database connection limit
max-idle-time: 30m
max-acquire-time: 3s # Timeout if no connection available
validation-query: SELECT 1
Why: R2DBC creates non-blocking connections, but they’re still limited by database server capacity. We tune pool size based on load testing.
4. Error Handling is Different
// Traditional try-catch doesn't work
public Mono<User> getUser(String id) {
return userRepo.findById(id)
.switchIfEmpty(Mono.error(new UserNotFoundException(id))) // Handle empty
.onErrorResume(DatabaseException.class, e -> // Handle DB errors
Mono.just(User.fallbackUser())
)
.doOnError(e -> log.error("Failed to fetch user: {}", id, e)) // Logging
.timeout(Duration.ofSeconds(5)); // Timeout
}
Gotcha: Forgot .onErrorResume() in production → uncaught errors crashed request processing pipeline.
Performance Testing Tools
We used these tools to validate reactive performance:
-
Gatling (load testing)
val scn = scenario("Onboarding Load Test") .exec(http("Verify KYC") .post("/api/onboarding/verify-kyc") .body(StringBody("""{"pan": "ABCDE1234F"}""")).asJson .check(status.is(200))) setUp( scn.inject( rampUsersPerSec(100) to 10000 during (2 minutes), // Ramp to 10K RPS constantUsersPerSec(10000) during (5 minutes) // Sustain 10K RPS ) ) -
Micrometer + Prometheus (metrics)
@Timed(value = "onboarding.verify.kyc", percentiles = {0.5, 0.95, 0.99}) public Mono<KycResponse> verifyKyc(KycRequest request) { // ... }Result: Grafana dashboard shows P50/P95/P99 latency in real-time.
-
OpenTelemetry (distributed tracing)
- Traces requests across microservices
- Identifies which API call is slowest
- Our finding: CKYC API was bottleneck (2-3 seconds)
Conclusion: Reactive is Worth It (If Done Right)
When we succeeded:
- High concurrency (10K+ requests)
- I/O-bound operations (8+ external APIs)
- Reactive all the way (R2DBC, WebClient, Reactive Kafka)
- Team trained on reactive patterns
Result: 94% latency reduction, 250x throughput increase, 67% memory savings.
When we struggled:
- Mixing blocking and non-blocking code
- Debugging cryptic stack traces
- Initial learning curve (2-3 weeks of training)
Bottom line: If you’re building high-concurrency systems with lots of I/O, Spring WebFlux + R2DBC is a game-changer. But don’t use it just because it’s trendy—use it when it solves a real scalability problem.
Resources
- Spring WebFlux Documentation
- Project Reactor Reference
- R2DBC Specification
- Reactive Streams Specification
Want to discuss reactive architecture? Contact me or connect on LinkedIn.
Related Posts:
- Coming soon: “R2DBC vs JDBC: Performance Benchmarks”
- Coming soon: “Building Production ML Pipelines with Spring WebFlux”