0%

Spring WebFlux: When and Why to Go Reactive

Technical article

Apr 15, 2026 7 min read Vaibhav Waghmare

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:

  1. 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
  2. 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);
    }
    
  3. Backpressure handling

    • When upstream produces data faster than downstream can consume
    • Reactive Streams specification has built-in backpressure support
  4. 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:

  1. 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
    }
    
  2. JDBC databases

    • JDBC is blocking by design
    • Calling jdbcTemplate.query() in a Mono.fromCallable() defeats the purpose
    • You MUST use R2DBC drivers (PostgreSQL, MySQL, H2, SQL Server support)
  3. 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, onErrorReturn vs try-catch)
  4. 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:

MetricBefore (Blocking)After (Reactive)Improvement
P95 Latency5.2 seconds287ms94% reduction
Throughput40 req/s (200 threads)10,000+ req/s (8 threads)250x increase
Database Latency120ms avg45ms avg60% reduction
Memory Usage1.2GB (thread stacks)400MB67% reduction
Thread Count200 (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:

  1. 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
      )
    )
    
  2. 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.

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

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”

Topics

Spring Boot WebFlux Reactive Programming Performance Java

Share this article

Related Articles