Back to Benchmarking & Load Testing
Q3Question 3 of 4

Reactive vs blocking benchmark mistakes?

Key Concepts
  • Coordinated omission flatters the reactive stack that queues internally; use an open-model generator for both.
  • Database saturation makes both stacks wait on PostgreSQL, measuring the database instead of the concurrency model.
  • Connection pool sizing must be correct for each model: JDBC needs more connections, R2DBC multiplexes over fewer.
  • R2DBC allocates many tiny short-lived objects per request; an undersized young generation causes GC storms.
  • Size the young generation generously for the reactive allocation rate; log GC to prove neither stack is collector-bound.
  • A fair comparison holds everything equal except the architecture: same generator, same database load, correct pools, tuned GC.
  • At low concurrency blocking often wins on raw latency because it avoids reactive overhead.

Definition

A fair reactive-versus-blocking benchmark must defeat coordinated omission, avoid measuring a saturated shared database, match and correctly size connection pools, and size the young generation so R2DBC's tiny-object allocation does not create unfair GC pressure.
  • Coordinated omission – closed-model generators hide tail latency unevenly; the reactive stack queues internally and looks flatteringly fast.
  • Database saturation – both stacks funnel into the same database; if it saturates, you measure the database, not the application model.
  • Connection pool sizing – JDBC pools need more connections (thread-per-request); R2DBC pools need fewer (non-blocking multiplexing).
  • GC pressure – R2DBC and the reactive pipeline allocate many tiny short-lived objects; an undersized young generation (-Xmn) causes frequent minor collections.
  • Fair comparison – hold everything equal except the architecture.

Imagine racing two delivery teams at National Bank of India, but the race is rigged four ways. The stopwatch pauses whenever a team is stuck (coordinated omission). Both must pass through the same narrow gate (database saturation). One team gets two vans while the other has fifty (pool sizing). The reactive team's paperwork fills the tiny inbox instantly (undersized young generation). A fair race fixes all four before you believe any number.

Understanding the Concepts

Why does coordinated omission affect reactive and blocking differently?

In a closed-model generator, the reactive stack queues work internally rather than blocking a thread, so under a closed model it absorbs bursts invisibly and reports a flattering p99. The blocking stack's thread exhaustion is more visible. The fix is an open, constant-arrival-rate generator for both.

How does database saturation ruin the comparison?

Both services send SQL to the same database. If load drives it to 100% CPU, both stacks wait on the database and their latencies converge. You are benchmarking PostgreSQL, not the concurrency model.

TEXT
# Fair isolation: stub the data layer with a fixed-delay fake
public class FakeAccountRepo implements AccountRepository {
    public Account findById(String id) {
        Thread.sleep(5);  // simulate 5 ms DB latency
        return new Account(id, new BigDecimal("100000"));
    }
}

How do you match connection pool sizing?

The blocking stack uses HikariCP. A common formula is pool size = cores x 2 + spindle count. The reactive stack uses R2DBC with a smaller pool because non-blocking I/O multiplexes many requests over few connections.

| Stack | Pool Type | Typical Size | Reason |
|-------|-----------|-------------|--------|
| Blocking (JDBC) | HikariCP | cores x 2 | One connection per thread |
| Reactive (R2DBC) | R2DBC Pool | cores x 1 | Non-blocking multiplexing |

Giving the blocking stack a tiny pool throttles it unfairly. Giving either a huge pool saturates the database.

How does GC pressure punish reactive unfairly?

R2DBC and the reactive pipeline allocate many tiny, short-lived objects per request: operator wrappers, subscriptions, signals, lambda captures, and small buffers. These die young and are cheap to collect if the young generation is large enough.

BASH
# WRONG: undersized young generation for reactive
java -Xmn128m -jar reactive-service.jar
# Eden fills constantly, minor GC storms, inflated p99

# CORRECT: generous young generation
java -Xmn1g -jar reactive-service.jar
# Or let G1 size it adaptively
java -XX:+UseG1GC -jar reactive-service.jar

What does a fair benchmark setup look like?

Same open-model generator, same unsaturated dependency, correctly sized pools for each model, and a JVM tuned so neither stack is starved of young-generation space.

What are common mistakes?

The wrong approach uses a closed generator, a saturated database, mismatched pools, and a tiny young generation.

The correct approach holds everything equal except the architecture.

When does blocking beat reactive?

At low concurrency blocking often wins on raw latency because it avoids the reactive overhead. Reactive's advantage is specifically thread efficiency under high concurrency. Quoting a low-concurrency result as proof reactive is slower is misleading.

What are common interview follow-up questions?

  • How do you measure allocation rate per request for each stack?
  • How do virtual threads change the reactive-vs-blocking comparison?
  • What is the minimum concurrency where reactive starts to win?

Visual Overview

DIAGRAM
  Four Benchmark Distortions:

  1. Coordinated Omission         2. Database Saturation
  ┌──────────────────────┐        ┌──────────────────────┐
  │ Closed generator     │        │ Both stacks → same DB│
  │ hides reactive tail  │        │ DB at 100% CPU       │
  │ Fix: open model      │        │ Fix: stub or isolate │
  └──────────────────────┘        └──────────────────────┘

  3. Pool Sizing Mismatch         4. GC Pressure (Young Gen)
  ┌──────────────────────┐        ┌──────────────────────┐
  │ Blocking: 2 conns    │        │ R2DBC: tiny objects   │
  │ vs Reactive: 50 conns│        │ Small -Xmn = GC storms│
  │ Fix: size correctly  │        │ Fix: generous -Xmn   │
  └──────────────────────┘        └──────────────────────┘

  Fair Comparison:
  Same open generator + same DB load + correct pools + tuned GC

Key Takeaway

When benchmarking reactive against blocking, the setup is the main risk. Four classic mistakes make the result meaningless. Coordinated omission hides the tail unevenly. Database saturation measures the database, not the model. Mismatched pool sizing artificially throttles one side. And an undersized young generation creates GC storms that unfairly punish the reactive stack's high allocation rate.

At National Bank of India our first benchmark called the reactive rewrite twenty percent slower, but it had a closed generator, a saturated database, a two-connection blocking pool, and a tiny young generation. Once we fixed all four, the two stacks were neck and neck at low concurrency and reactive pulled ahead under high concurrency.

Remember one sentence: an architecture benchmark is only trustworthy when everything except the architecture is held equal.

---

Share
Use ← → arrow keys to navigate