Modern event-driven systems often rely on Kafka for high-throughput messaging. But how you consume these messages—using traditional blocking I/O or reactive non-blocking pipelines—can greatly impact performance and scalability. Let’s quickly break down the key differences through a real-world lens.
Table of Contents
Setup Snapshot
We tested both models by consuming Kafka trade events in a Spring Boot app:
-
Blocking Kafka Consumer: Uses
@KafkaListenerand Spring Web (spring-boot-starter-web) -
Reactive Kafka Consumer: Uses
Flux<ConsumerRecord>from Reactor and WebFlux (spring-boot-starter-webflux)
Both save data into PostgreSQL using JPA or R2DBC, depending on the model.
What Actually Happens Inside the JVM?
🔸 Blocking Path (Spring Web + JPA)
-
Each Kafka message is handled on a dedicated thread
-
That thread waits (blocks) for:
-
I/O (DB, network)
-
Locks
-
Internal queues
-
-
Thread pool grows under load
-
JVM shows more memory churn, increased GC activity
-
Higher pressure on CPU with context switching
🔸 Reactive Path (WebFlux + R2DBC)
-
Message flows through a reactive pipeline
-
Single thread handles many tasks (non-blocking)
-
DB calls are async via R2DBC
-
JVM shows stable thread count, low GC frequency
-
CPU works more predictably, even under heavy load
Key Learnings (Blocking vs. Reactive)
| Topic | Blocking (Spring Web) | Reactive (WebFlux) |
|---|---|---|
| Thread Usage | Many threads. Each request holds a thread while waiting. | Fewer threads. Event loop-based, non-blocking execution. |
| Latency | Higher under load. Blocked I/O delays response. | Lower latency even under load due to async processing. |
| Resource Pressure | High CPU and memory with concurrent load. | Efficient CPU/memory. GC is calmer. |
| Kafka Message Handling | Thread handles each message end-to-end. | Split across async stages (subscribe, flatMap, etc). |
| Backpressure | Needs explicit logic. | Built-in via reactive streams. |
| Throughput | Drops sharply if I/O slows. | Stays stable, scales with demand. |
What to Look For in Logs
When comparing both models, your logs tell a lot:
-
Blocking:
-
Threads idle or waiting
-
GC spikes during high throughput
-
Request time grows linearly with load
-
-
Reactive:
-
Fewer active threads
-
Messages processed in batches
-
Consistent latency and response size
-
Spring Boot Actuator Differences
Use http://localhost:8080/actuator endpoints (like /metrics, /health) to observe:
-
Blocking apps:
-
jvm.threads.liveandsystem.cpu.usagespike under load -
Slow DB calls visible in
/metrics/jdbc.connections.active
-
-
Reactive apps:
-
Stable
reactor.threads.executor.pool.size -
Faster
http.server.requestswith smaller deviations -
Better resilience under load visible via
/metrics/reactor.*
-
Final Thought
If your service needs to scale with minimal resources, reactive wins. If you prioritize simplicity and already deal with I/O-bound code in small bursts, blocking may suffice.
Either way, understanding what happens under the hood—from threads to GC to logs—is what makes the difference between average and high-performing apps.