Deepen PracticeOrdered learning track

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.

15 min read2804 words
PrevNext
Lesson 5060 lesson track34–50 Deepen Practice
#java#data-access#reactive#r2dbc+6 more

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:

  1. What is current bottleneck?
  2. Is it thread exhaustion or DB/query?
  3. What is target concurrency?
  4. What is average/p95 DB latency?
  5. What is connection pool size?
  6. Are queries optimized?
  7. Are there blocking calls in path?
  8. Can virtual threads solve it?
  9. Is driver mature?
  10. 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

QuestionReactive FavorsImperative Favors
Stack already reactive?yesno
Team Reactor expertise?highlow/moderate
Blocking libraries unavoidable?noyes
Need JPA ORM?noyes
DB driver R2DBC mature?yesuncertain
Bottleneck thread count?yesno
Bottleneck slow SQL/DB?noyes
Transaction logic simple?yescomplex
Need streaming non-blocking?yesno
Virtual threads sufficient?noyes
Debugging simplicity priority?lowerhigh

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:

  1. Would you choose R2DBC?
  2. Would virtual threads + JDBC be enough?
  3. What is actual bottleneck?
  4. How would you protect DB pool?
  5. What query/read model work is needed?
  6. What load test compares alternatives?
  7. What operational skills are missing?
  8. What if future WebSocket streaming needed?
  9. What if many blocking dependencies exist?
  10. 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

Lesson Recap

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.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.