Reactive Data Access Tradeoffs
Learn Java Data Access Pattern In Action - Part 050
Trade-off reactive data access Java: kapan R2DBC masuk akal, kapan JDBC lebih benar, event loop, blocking trap, pool vs database bottleneck, virtual threads, debugging, transaction complexity, team readiness, dan decision framework.
Part 050 — Reactive Data Access Tradeoffs
Reactive data access sering dipromosikan sebagai jalan menuju scalability.
Kadang benar.
Tetapi sering juga reactive hanya memindahkan kompleksitas:
- dari blocking thread ke event loop;
- dari stack trace sederhana ke chain debugging;
- dari transaction ThreadLocal ke reactive context;
- dari imperative flow ke composition;
- dari database bottleneck ke pool/backpressure problem;
- dari simple JDBC ke driver maturity risk.
Reactive adalah trade-off, bukan upgrade otomatis.
Part ini membahas kapan reactive/R2DBC masuk akal, kapan JDBC lebih benar, dan bagaimana membandingkannya dengan virtual threads.
1. Core Thesis
Pilih reactive data access jika manfaatnya mengalahkan kompleksitasnya.
Reactive membantu terutama ketika:
High concurrency + I/O-bound + non-blocking end-to-end + team expertise + mature drivers.
Reactive tidak membantu banyak ketika:
Database is bottleneck + workload simple + blocking stack sufficient + team not reactive-ready.
JDBC blocking dengan connection pool, terutama dengan virtual threads, sering lebih sederhana dan cukup cepat.
2. What Reactive Optimizes
Reactive optimizes thread utilization.
Blocking JDBC:
one request waits on DB
one platform/virtual thread parked
connection held
Reactive R2DBC:
request registers DB I/O
event loop handles other work
connection held while DB operation active
result arrives asynchronously
Reactive saves blocking thread resources, not database resources.
Database still executes query, holds locks, uses CPU/IO, and consumes connection capacity.
3. Database Bottleneck Remains
If query scans 10 million rows, reactive does not fix it.
If lock waits 5 seconds, reactive does not remove lock.
If index missing, reactive does not create index.
If pool size is 50, reactive does not allow 500 true concurrent DB operations.
Fix first:
- query shape;
- indexes;
- transaction length;
- connection pool;
- backpressure;
- read model;
- cache where safe;
- async job.
Then decide reactive.
4. Thread Model Comparison
Traditional JDBC on platform threads
Request thread blocks during DB I/O.
Pros:
- simple mental model;
- mature ecosystem;
- easy debugging.
Cons:
- many concurrent blocked threads are expensive;
- thread pool exhaustion risk.
JDBC on virtual threads
Virtual thread blocks, carrier thread can run others.
Pros:
- imperative code;
- high concurrency for blocking I/O;
- mature JDBC;
- simple transactions.
Cons:
- connection pool still bottleneck;
- pinned/blocking caveats;
- not all libraries equally friendly.
R2DBC reactive
Event loop non-blocking DB I/O.
Pros:
- low thread overhead;
- composable non-blocking stack;
- good for streaming/I/O chains.
Cons:
- higher cognitive complexity;
- driver/ecosystem maturity varies;
- transaction/context/debugging harder;
- blocking trap severe.
5. Virtual Threads Change the Decision
Virtual threads reduce the main pain of blocking I/O: platform thread cost.
Imperative JDBC with virtual threads can support high concurrency while keeping code simple.
But virtual threads do not:
- increase DB connection count;
- reduce query time;
- remove locks;
- fix slow SQL;
- remove need for timeouts/backpressure.
They make blocking code scale better at the thread level.
This means R2DBC must justify itself beyond "threads are expensive."
6. Reactive End-to-End Requirement
Reactive is most valuable when entire request path is non-blocking:
HTTP server
DB driver
cache client
message client
HTTP client
file/object storage client
serialization
security context
If pipeline has blocking calls, event loop can stall.
Bridging blocking calls to bounded elastic is possible but reduces benefit and adds scheduler complexity.
7. Blocking Trap
Bad reactive code:
return caseRepository.findById(id)
.map(caseFile -> jdbcTemplate.queryForObject(...));
This blocks event loop.
Also bad:
return caseRepository.findById(id)
.map(caseFile -> restTemplate.getForObject(...));
If blocking library unavoidable:
Mono.fromCallable(() -> blockingCall())
.subscribeOn(Schedulers.boundedElastic())
But now you have hybrid model. Use carefully.
8. Hybrid Complexity
Hybrid reactive/blocking app has:
- event loop;
- bounded elastic scheduler;
- blocking pools;
- reactive context propagation;
- ThreadLocal issues;
- transaction boundary risks;
- different timeout layers.
Sometimes simpler:
Use imperative stack with virtual threads.
unless reactive benefits are clear.
9. Connection Pool Is Still Backpressure Gate
Both JDBC and R2DBC need pool.
If app accepts 10,000 concurrent requests and DB pool is 100:
9,900 wait/queue/fail
You need:
- request admission control;
- pool acquisition timeout;
- bulkheads;
- queue limits;
- load shedding;
- bounded
flatMap; - per-endpoint concurrency limits.
Reactive can make it easier to queue many operations, which can be bad without limits.
10. Reactive Backpressure Is Not Enough
Backpressure controls publisher/subscriber flow.
It does not automatically protect database from:
- too many concurrent subscriptions;
- many active queries;
- slow SQL;
- locks;
- pool starvation;
- unbounded request queue.
You must explicitly bound concurrency.
11. Example: Unbounded flatMap Incident
Flux.fromIterable(10_000_items)
.flatMap(item -> repository.update(item))
.then();
This may create 10,000 pending DB operations.
Better:
Flux.fromIterable(items)
.flatMap(item -> repository.update(item), 32)
.then();
or chunk/batch.
12. Transaction Complexity
Imperative:
@Transactional
public void approve(Command command) {
updateCase();
insertAudit();
insertOutbox();
}
Reactive:
@Transactional
public Mono<Void> approve(Command command) {
return updateCase()
.then(insertAudit())
.then(insertOutbox());
}
Mistakes:
- forgetting to return chain;
- manual subscribe;
- swallowing error;
- mixing schedulers;
- losing context.
Reactive transaction demands more discipline.
13. Debugging Complexity
Imperative stack trace is usually direct.
Reactive stack trace can be asynchronous and operator-heavy.
Tools help:
- checkpoints;
- operator debug in dev;
- tracing;
- structured logging;
- metrics;
- StepVerifier tests.
But team must know how to debug reactive code.
This is a real cost.
14. Error Handling Complexity
Reactive:
.onErrorResume(...)
.onErrorMap(...)
.retryWhen(...)
.timeout(...)
Powerful but easy to misuse.
Common bugs:
- swallowing required error and committing transaction;
- retrying non-idempotent operation;
- retrying only tail of transaction;
- timeout cancel not cancelling DB query as expected;
- mapping all errors to fallback success.
Imperative error handling is often simpler to audit.
15. Context Propagation Complexity
In imperative stack:
ThreadLocal tenant/security/MDC often works.
In reactive stack:
Thread changes; context must be Reactor Context-aware.
Safer pattern:
Pass tenant/security scope explicitly in command/query object.
Do not rely on hidden ThreadLocal tenant unless integration is proven.
16. Driver Maturity
JDBC drivers are extremely mature.
R2DBC driver support varies by database and feature.
Before choosing R2DBC, verify:
- transaction semantics;
- isolation levels;
- generated keys;
- batch;
- prepared statement behavior;
- time/JSON/array types;
- lock timeout;
- cancellation;
- SSL/auth;
- observability;
- connection pool stability;
- production adoption for your DB.
This is not theoretical. Driver behavior matters.
17. Library Ecosystem
JPA/Hibernate ecosystem offers:
- mature ORM;
- Spring Data JPA;
- transaction integrations;
- migration patterns;
- monitoring knowledge;
- existing team familiarity.
Reactive ecosystem offers:
- WebFlux;
- Spring Data R2DBC;
- Reactor;
- reactive clients.
If your domain needs JPA features like persistence context, lazy loading, cascades, and dirty checking, R2DBC is not a drop-in replacement.
18. Reactive and Domain Modeling
Reactive pipeline can still use domain objects.
return repository.load(caseId)
.switchIfEmpty(Mono.error(new CaseNotFound(caseId)))
.flatMap(caseFile -> {
caseFile.approve(actor, reason);
return repository.save(caseFile)
.then(outbox.append(...))
.thenReturn(result);
});
But avoid blocking domain methods.
Domain method itself should be CPU-only/in-memory.
Any data access inside domain method is impossible/awkward and should be repository responsibility.
19. Reactive and Aggregates
R2DBC does not naturally hydrate complex aggregate graph like ORM.
This can be good:
- explicit queries;
- no lazy surprise.
But if aggregate load requires multiple queries:
Mono.zip(
caseDao.findHeader(id),
assignmentDao.findActive(id).collectList(),
policyDao.findSnapshot(id)
)
Need transaction/consistency design.
Do not overcomplicate with many nested reactive calls if imperative JDBC is clearer.
20. Reactive and Read Path
Reactive works well for:
- streaming response from DB to client;
- high-concurrency small queries;
- non-blocking composition with other I/O;
- read APIs where each request waits on external services too;
- SSE/WebSocket/feed-style endpoints.
But for normal paginated CRUD API, imperative JDBC/JPA may be simpler and sufficient.
21. Reactive and Write Path
Write path needs:
- transaction boundary;
- idempotency;
- affected row checks;
- audit/outbox;
- rollback;
- retry.
Reactive can do it, but code becomes chain composition.
If team struggles to read/write reactive transactions, correctness risk increases.
Simplicity is a reliability feature.
22. Reactive and Batch Jobs
Reactive is not automatically better for batch.
Batch jobs often need:
- checkpoint;
- chunk transaction;
- retry;
- backpressure to DB;
- file/object storage;
- rate limiting;
- audit.
Imperative chunk loop is often clearer.
Reactive batch can work if pipeline is truly non-blocking and carefully bounded.
23. Reactive and Streaming Export
Reactive streaming from DB to HTTP client looks attractive.
Risk:
- slow client holds DB connection;
- cancellation complexity;
- long transaction/snapshot;
- retry/resume hard.
Alternative:
async export job -> write file/object storage -> return download link
Often better for large export.
Use reactive streaming for bounded/live stream where resource plan is explicit.
24. Performance Decision Questions
Before reactive, ask:
- What is current bottleneck?
- Is it thread exhaustion or DB/query?
- What is target concurrency?
- What is average/p95 DB latency?
- What is connection pool size?
- Are queries optimized?
- Are there blocking calls in path?
- Can virtual threads solve it?
- Is driver mature?
- Can team debug reactive incidents?
If answers are unclear, reactive adoption is risky.
25. Load Testing Comparison
Compare:
A. JDBC + platform threads
B. JDBC + virtual threads
C. R2DBC reactive
Same:
- database;
- queries;
- pool size;
- endpoint behavior;
- data volume;
- concurrency ramp;
- timeout;
- hardware.
Measure:
- throughput;
- p95/p99 latency;
- DB CPU/IO;
- connection pool wait;
- memory;
- thread count;
- error rate;
- tail behavior under overload.
Do not decide based on ideology.
26. Overload Behavior
Reactive system can keep accepting work until memory/queues explode unless bounded.
Design overload:
- reject early;
- return 429/503;
- pool acquisition timeout;
- per-endpoint concurrency;
- bulkhead;
- circuit breaker for downstream;
- bounded queues.
Same for imperative, but reactive queues can be less obvious.
27. Latency Budget
Example:
GET /dashboard p95 target 150ms
DB query p95 80ms
pool wait p95 < 10ms
serialization p95 20ms
If DB query p95 is 800ms, reactive thread saving does not meet target.
Fix SQL/read model/index.
28. CPU-Bound Work
Reactive does not speed CPU-bound work.
If request spends time in:
- JSON transformation;
- cryptography;
- report generation;
- image/PDF generation;
- complex in-memory computation;
event loop can be blocked unless offloaded.
Virtual threads do not solve CPU either; use bounded compute pool and optimize algorithm.
29. Reactive and JPA
JPA/Hibernate is blocking. Do not call JPA from event loop.
If you need JPA, use imperative stack or offload blocking to bounded scheduler.
But "reactive WebFlux + blocking JPA everywhere" often adds complexity without much benefit.
Use Spring MVC + virtual threads maybe simpler.
30. Reactive and jOOQ/MyBatis/JDBC
jOOQ/MyBatis/JDBC are blocking unless using specific async/non-blocking integrations.
Do not wrap large blocking SQL usage in reactive pipeline casually.
If SQL-first and non-blocking required, use R2DBC with SQL client or check jOOQ reactive/R2DBC support suitability for your use case.
31. Simplicity Decision
If two designs meet requirements, prefer simpler one.
Imperative JDBC/JPA with virtual threads may be simpler for many backend services.
Reactive is justified when measurable need exists.
Top 1% engineer knows when not to use powerful tool.
32. When Reactive Is Worth It
Reactive/R2DBC often worth considering for:
- high concurrent I/O-bound gateway;
- WebFlux service already reactive;
- streaming moderate result to non-blocking clients;
- composition of many non-blocking downstream calls;
- event-driven pipeline with reactive clients;
- low thread budget environment;
- backpressure-aware internal flows;
- team already strong in Reactor;
- R2DBC driver supports required DB features.
33. When JDBC Is Better
JDBC/JPA/jOOQ/MyBatis better when:
- typical CRUD/OLTP service;
- domain transaction logic complex;
- team imperative;
- ORM features needed;
- queries are optimized and pool is enough;
- virtual threads available;
- blocking clients unavoidable;
- driver maturity critical;
- debugging simplicity important;
- batch jobs easier chunked imperative.
34. Virtual Threads + JDBC Pattern
With virtual threads:
@Transactional
public ApproveCaseResult approve(Command command) {
CaseFile caseFile = repository.loadForApproval(command.caseId()).orElseThrow();
caseFile.approve(...);
repository.save(caseFile);
audit.append(...);
outbox.append(...);
return result;
}
This remains simple.
Thread can park during JDBC call. Connection pool still bounds DB concurrency.
You still need:
- timeout;
- pool limits;
- query optimization;
- idempotency;
- transaction discipline.
35. Reactive Decision Matrix
| Question | Reactive Favors | Imperative Favors |
|---|---|---|
| Stack already reactive? | yes | no |
| Team Reactor expertise? | high | low/moderate |
| Blocking libraries unavoidable? | no | yes |
| Need JPA ORM? | no | yes |
| DB driver R2DBC mature? | yes | uncertain |
| Bottleneck thread count? | yes | no |
| Bottleneck slow SQL/DB? | no | yes |
| Transaction logic simple? | yes | complex |
| Need streaming non-blocking? | yes | no |
| Virtual threads sufficient? | no | yes |
| Debugging simplicity priority? | lower | high |
Use this as starting point, not absolute rule.
36. Common Migration Mistake
Migrating controller to WebFlux while keeping blocking repository:
@GetMapping
public Mono<Response> get(...) {
return Mono.just(blockingService.get(...));
}
This blocks before Mono exists.
Even:
Mono.fromCallable(() -> blockingService.get(...))
needs scheduler and still uses blocking resources.
A true migration requires non-blocking path or deliberate hybrid strategy.
37. Reactive Islands
A small reactive island in an imperative app may be useful for streaming/event pipeline.
But mixing paradigms in core request path can confuse team.
Define boundary:
Imperative service core.
Reactive publisher worker.
or:
Reactive API edge.
Blocking legacy adapter on bounded scheduler.
Document it.
38. Operational Readiness
Reactive production requires:
- thread/scheduler metrics;
- connection pool metrics;
- blocked event loop detection;
- context propagation;
- tracing support;
- good log correlation;
- load test;
- team debugging runbook.
Without these, reactive incidents are hard.
39. Testing Readiness
Need tests for:
- transaction rollback;
- timeout;
- cancellation;
- no blocking calls;
- bounded concurrency;
- error mapping;
- retry idempotency;
- context/tenant propagation;
- pool exhaustion behavior maybe in load tests.
Unit tests alone are insufficient.
40. Cancellation Semantics
Reactive clients can cancel.
For DB query:
- does driver cancel query?
- is connection returned to pool?
- is transaction rolled back?
- are partial side effects possible?
- are resources released?
For read stream, cancellation may be fine.
For write transaction, ensure cancellation/error rolls back or operation completes safely.
41. Timeouts Are Layered
Layers:
- HTTP request timeout;
- reactive
.timeout; - transaction timeout;
- pool acquisition timeout;
- statement/query timeout;
- lock timeout;
- downstream client timeout.
They should be coherent.
If HTTP times out but DB query continues, resources remain wasted.
Use DB/driver timeout when possible.
42. Backpressure to HTTP Clients
Streaming Flux to client may apply network backpressure.
But if DB result already buffered or transaction open, resource usage persists.
For large data, prefer page/cursor/export job.
43. Reactive and Outbox Publisher
Reactive can be good for outbox publisher:
claim batch -> publish with bounded concurrency -> mark published
But publishing external messages is I/O and must be idempotent.
Use:
- bounded concurrency;
- claim lease;
- retry policy;
- dead-letter;
- mark published with ownership check;
- consumer idempotency.
Reactive fits but still requires data correctness design.
44. Reactive and Inbox Consumer
Message consumer can process events reactively.
Rules:
- start inbox idempotency record;
- process in transaction;
- update read model;
- mark processed;
- ack message only after commit;
- bounded concurrency per partition/key;
- preserve order if required.
Reactive flatMap concurrency can break ordering. Use groupBy/concatMap carefully if ordering per aggregate matters.
45. Ordering Risk
events.flatMap(event -> process(event), 32)
may process events for same aggregate concurrently/out of order.
If read model relies on version check, old event ignored can be okay.
If operation requires order, use:
events.groupBy(Event::aggregateId)
.flatMap(group -> group.concatMap(this::process), concurrency)
Still design source version/idempotency.
46. Reactive Review Checklist
- Measured bottleneck is thread/I/O concurrency, not slow DB.
- Stack is non-blocking end-to-end or blocking bridges isolated.
- R2DBC driver supports needed DB features.
- Team can debug Reactor.
- Transaction composition returns full chain.
- No manual subscribe.
- Blocking calls detected.
- Pool/concurrency bounded.
- Timeout/cancellation semantics understood.
- Retry idempotent and wraps whole transaction.
- Tenant/context explicitly passed or tested.
- Large result streaming resource plan exists.
- Observability and load tests exist.
- Virtual threads/JDBC alternative evaluated.
47. Anti-Pattern: Reactive Because Trendy
Use reactive for a measured reason.
48. Anti-Pattern: WebFlux + Blocking JPA Everywhere
Often worse than simple MVC/JPA or MVC/virtual threads.
49. Anti-Pattern: Infinite DB Concurrency
Reactive does not remove database limits.
50. Anti-Pattern: flatMap Without Concurrency Limit
Can overload DB/downstream.
51. Anti-Pattern: Ignoring Driver Feature Gaps
Production requirements must match driver support.
52. Anti-Pattern: Reactive Export Holding DB Connection for Slow Client
Use async export job/chunks unless streaming requirement is explicit and bounded.
53. Mini Lab
You are designing a new service:
Case Search API
- high traffic;
- dashboard queries;
- some command writes;
- PostgreSQL;
- team experienced with Spring MVC/JPA/jOOQ, little Reactor;
- Java 21 virtual threads available;
- query p95 currently 70ms with optimized SQL;
- target 5000 concurrent clients but DB pool 100.
Decision questions:
- Would you choose R2DBC?
- Would virtual threads + JDBC be enough?
- What is actual bottleneck?
- How would you protect DB pool?
- What query/read model work is needed?
- What load test compares alternatives?
- What operational skills are missing?
- What if future WebSocket streaming needed?
- What if many blocking dependencies exist?
- What is your recommended architecture?
54. Summary
Reactive data access is a trade-off.
You must master:
- what reactive optimizes;
- DB bottleneck reality;
- thread model comparison;
- virtual threads impact;
- reactive end-to-end requirement;
- blocking trap;
- connection pool/backpressure limits;
- transaction complexity;
- error/retry complexity;
- context propagation;
- driver maturity;
- domain/read/write/batch implications;
- streaming export risk;
- load test decision;
- overload behavior;
- decision matrix;
- operational readiness;
- cancellation/timeouts;
- event/outbox/inbox ordering.
Part berikutnya membahas Async Boundaries and Database Pressure: async tidak menghilangkan bottleneck database; queue, backpressure, rate limit, bulkhead, timeout, and how to protect DB under load.
55. References
- R2DBC Specification: https://r2dbc.io/spec/1.0.0.RELEASE/spec/html/
- Spring Data R2DBC Reference: https://docs.spring.io/spring-data/relational/reference/r2dbc.html
- Project Reactor Reference: https://projectreactor.io/docs/core/release/reference/
- Java Virtual Threads: https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
- Reactive Streams: https://www.reactive-streams.org/
You just completed lesson 50 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.