Reactive and Virtual Thread Selection
Purpose
Turn "which model?" into a decision with named criteria, evidence and a stated cost, instead
of a preference. Both models are correct engineering for different problems, and the answer
for one endpoint is frequently not the answer for the one next to it.
The failures this prevents are symmetrical: rewriting a working reactive streaming service
into blocking code because virtual threads arrived, and adding a blocking call to a reactive
pipeline because the deadline was tight.
Compatibility and evidence
Inspect the project's compiler/runtime, resolved framework versions, server and executor
configuration before choosing a model. Virtual threads are final in Java 21; this does not
authorize a runtime upgrade or a stack rewrite. StructuredTaskScope is version-specific
preview API on Java 21–25, not a prerequisite for thread-per-request. JDK 24's JEP 491 removes
monitor-related pinning in HotSpot; remaining blocking and pinning depend on the operation
and runtime. Route their diagnosis to blocking-and-nonblocking-io.
If workload, limits or runtime evidence are missing, keep the choice conditional and name
the observation needed. Preserve existing streaming, ordering, cancellation and transaction
contracts while comparing alternatives.
Workflow
- Describe the workload, not the framework. Request/response or a long-lived stream?
Bounded work per request or unbounded? I/O-bound or CPU-bound? Thousands of active
requests or millions of mostly-idle connections?
- Find where the bound already comes from. Inspect demand, operator concurrency, scheduler queues and admission separately;
a platform pool bounds executing tasks but may leave an unbounded queue. If a migration
removes one, name its replacement before the migration, not after.
- Price the migration honestly. Rewriting a working pipeline costs the rewrite, the
regression risk and a period of two models — against measured benefits in this service, including diagnosability, capacity and latency.
- Decide per boundary, not per service. A streaming endpoint and a CRUD endpoint in the
same application can legitimately use different models; what must not vary is which one
a given path uses.
- Configure the framework explicitly and write down which requests run where. The most
common production surprise is a framework default that nobody chose.
- Verify with load, not with reasoning. Concurrency, tail latency and memory at the
target connection count settle this; a benchmark of a hello-world endpoint settles
nothing.
Decision rules
Long-lived stream where the consumer can be slower than the producer
(SSE, WebSocket fan-out, Kafka pipeline, database cursor to network)
→ reactive is a strong candidate when demand reaches the producer.
Imperative bounded queues, pull iteration and explicit flow control can
also work. Check hot sources and transport boundaries: SSE/WebSocket
do not themselves guarantee application-level demand end to end.
Time-shaped composition: window, debounce, sample, buffer-with-timeout,
groupBy over a live stream
→ reactive. These operators are the reason the library exists.
Request/response with blocking clients (JDBC, most SDKs, existing code)
→ consider virtual threads on a compatible stack. Thread-per-request with a real stack, ordinary
try/catch, and a stack trace that names the request.
Millions of mostly-idle connections on one process
→ measure. A parked virtual thread's stack is heap and a reactive
subscription has its own operator/context state. Either can decide
machine size at high cardinality; no universal crossover exists.
CPU-bound work
→ bound parallelism near available CPU. Either API can orchestrate it,
but virtual-thread cardinality and reactive demand do not add cores;
compare a fixed pool, ForkJoinPool, batching and vectorisation.
An existing reactive system that works, with a team that understands it
→ keep it. "Virtual threads exist" is not a defect report.
A new service, blocking dependencies, ordinary request/response
→ virtual threads are a strong default candidate when the framework,
libraries and team support them. Retain reactive when end-to-end
demand, existing investment or streaming composition outweighs it.
Rules
- Virtual threads do not make reactive programming obsolete. They remove one of its
motivations — avoiding a thread per blocking call — and leave the others: demand-driven
flow control, time-based operators, and composition over asynchronous event sources.
- Reactive programming does not automatically give backpressure. It gives a protocol for
it, but compliance does not imply bounded memory or request admission. Unbounded
onBackpressureBuffer can honor downstream demand while requesting unbounded upstream;
large publishOn queues and flatMap concurrency can exhaust a resource without violating
the protocol. Inspect each Sinks variant's actual overflow contract. See reactive-backpressure.
- Thread-per-request has backpressure only where a bounded resource exists. A pool or semaphore bounds active work, not necessarily queued requests,
waiters or bytes. Bound admission and waiting time as well as execution
(
concurrency-limiting-and-bulkheads).
- Blocking on an event-loop/non-blocking scheduler combines the models' failure modes: it
stalls a thread serving many connections. A reactive client called from a virtual thread
is not inherently pointless; make one deliberate conversion at the boundary and avoid
alternating
block/resubscribe layers down the call graph.
- The models expose different diagnostic evidence. A thread-per-request dump often preserves
a request stack; an asynchronous pipeline usually requires assembly checkpoints,
correlation context, scheduler metrics and traces because no thread owns the request for
its whole lifetime.
- Neither model changes the downstream. A connection pool of 20, a vendor quota of 600
requests per minute, or a database that saturates at 4 000 IOPS bound both identically.
A large measured improvement may remove a former bottleneck; distinguish useful completed
throughput from shifted queues, dropped work and changed latency or correctness.
- A mixed codebase is acceptable; an undocumented mixed codebase is not. Every endpoint
should have a stated model, and the boundary between them should be one place where the
handoff is explicit.
- Framework behaviour is not platform behaviour.
spring.threads.virtual.enabled,
@RunOnVirtualThread and Helidon's virtual-thread server are decisions those projects
made; none of them is something "Java does". State which layer a claim belongs to.
- Do not benchmark the model. Benchmark the service, with the real dependencies, at the real
concurrency, measuring useful throughput, tail latency, errors, retained memory and overload recovery.
- Changing threads does not propagate a transaction, security identity or Reactor Context
automatically. State the context carrier and transaction owner at each asynchronous handoff;
avoid sharing a persistence context across concurrent tasks.
Deliverable
Record the chosen boundary and retained contracts, compatible runtime/configuration, active
and waiting limits with overflow behavior, evidence versus remaining hypotheses, and a load
or slow-consumer check that could reopen the choice. A small change needs only a short note.
References
- The comparison, dimension by dimension — the full matrix
with an honest column for each model, memory arithmetic per in-flight request and per idle
connection, backpressure sources, failure modes under overload, and the hybrid designs that
work. Read when the decision is genuinely open, or when writing it up for a team.
- Framework execution models — exactly which
Spring, Quarkus, Jakarta and Helidon settings put a request on which kind of thread, what
each one silently unbounds, and how to verify at runtime which model a request actually
ran under. Read before changing a framework flag or reviewing one.
1---2name: reactive-and-virtual-thread-selection3description: Choosing between a reactive pipeline and thread-per-request on virtual threads, and deciding where they legitimately coexist: what each model actually gives you, where backpressure comes from in each, memory per in-flight request versus per idle connection, the diagnosability difference, and the framework configuration that decides which model a request runs under. Use when a team proposes migrating away from WebFlux or towards it, when virtual threads are described as making reactive obsolete, when a blocking call is about to be added to a reactive pipeline, when spring.threads.virtual.enabled is being turned on, when Quarkus RunOnVirtualThread is applied per endpoint, or when both models exist in one service and nobody can say which runs what. Not what blocks a carrier (blocking-and-nonblocking-io), demand and overflow (reactive-backpressure), the migration programme (virtual-thread-migration), or thread costs and sizing (thread-sizing-and-virtual-threads).4---56# Reactive and Virtual Thread Selection78## Purpose910Turn "which model?" into a decision with named criteria, evidence and a stated cost, instead11of a preference. Both models are correct engineering for different problems, and the answer12for one endpoint is frequently not the answer for the one next to it.1314The failures this prevents are symmetrical: rewriting a working reactive streaming service15into blocking code because virtual threads arrived, and adding a blocking call to a reactive16pipeline because the deadline was tight.1718## Compatibility and evidence1920Inspect the project's compiler/runtime, resolved framework versions, server and executor21configuration before choosing a model. Virtual threads are final in Java 21; this does not22authorize a runtime upgrade or a stack rewrite. `StructuredTaskScope` is version-specific23preview API on Java 21–25, not a prerequisite for thread-per-request. JDK 24's JEP 491 removes24monitor-related pinning in HotSpot; remaining blocking and pinning depend on the operation25and runtime. Route their diagnosis to `blocking-and-nonblocking-io`.2627If workload, limits or runtime evidence are missing, keep the choice conditional and name28the observation needed. Preserve existing streaming, ordering, cancellation and transaction29contracts while comparing alternatives.3031## Workflow32331. **Describe the workload, not the framework.** Request/response or a long-lived stream?34 Bounded work per request or unbounded? I/O-bound or CPU-bound? Thousands of active35 requests or millions of mostly-idle connections?362. **Find where the bound already comes from.** Inspect demand, operator concurrency, scheduler queues and admission separately;37 a platform pool bounds executing tasks but may leave an unbounded queue. If a migration38 removes one, name its replacement before the migration, not after.393. **Price the migration honestly.** Rewriting a working pipeline costs the rewrite, the40 regression risk and a period of two models — against measured benefits in this service, including diagnosability, capacity and latency.414. **Decide per boundary, not per service.** A streaming endpoint and a CRUD endpoint in the42 same application can legitimately use different models; what must not vary is which one43 a given path uses.445. **Configure the framework explicitly** and write down which requests run where. The most45 common production surprise is a framework default that nobody chose.466. **Verify with load, not with reasoning.** Concurrency, tail latency and memory at the47 target connection count settle this; a benchmark of a hello-world endpoint settles48 nothing.4950## Decision rules5152```text53Long-lived stream where the consumer can be slower than the producer54 (SSE, WebSocket fan-out, Kafka pipeline, database cursor to network)55 → reactive is a strong candidate when demand reaches the producer.56 Imperative bounded queues, pull iteration and explicit flow control can57 also work. Check hot sources and transport boundaries: SSE/WebSocket58 do not themselves guarantee application-level demand end to end.5960Time-shaped composition: window, debounce, sample, buffer-with-timeout,61groupBy over a live stream62 → reactive. These operators are the reason the library exists.6364Request/response with blocking clients (JDBC, most SDKs, existing code)65 → consider virtual threads on a compatible stack. Thread-per-request with a real stack, ordinary66 try/catch, and a stack trace that names the request.6768Millions of mostly-idle connections on one process69 → measure. A parked virtual thread's stack is heap and a reactive70 subscription has its own operator/context state. Either can decide71 machine size at high cardinality; no universal crossover exists.7273CPU-bound work74 → bound parallelism near available CPU. Either API can orchestrate it,75 but virtual-thread cardinality and reactive demand do not add cores;76 compare a fixed pool, ForkJoinPool, batching and vectorisation.7778An existing reactive system that works, with a team that understands it79 → keep it. "Virtual threads exist" is not a defect report.8081A new service, blocking dependencies, ordinary request/response82 → virtual threads are a strong default candidate when the framework,83 libraries and team support them. Retain reactive when end-to-end84 demand, existing investment or streaming composition outweighs it.85```8687## Rules8889- Virtual threads do not make reactive programming obsolete. They remove **one** of its90 motivations — avoiding a thread per blocking call — and leave the others: demand-driven91 flow control, time-based operators, and composition over asynchronous event sources.92- Reactive programming does not automatically give backpressure. It gives a **protocol** for93 it, but compliance does not imply bounded memory or request admission. Unbounded94 `onBackpressureBuffer` can honor downstream demand while requesting unbounded upstream;95 large `publishOn` queues and `flatMap` concurrency can exhaust a resource without violating96 the protocol. Inspect each `Sinks` variant's actual overflow contract. See `reactive-backpressure`.97- Thread-per-request has backpressure only where a bounded resource exists. A pool or semaphore bounds active work, not necessarily queued requests,98 waiters or bytes. Bound admission and waiting time as well as execution99 (`concurrency-limiting-and-bulkheads`).100- Blocking on an event-loop/non-blocking scheduler combines the models' failure modes: it101 stalls a thread serving many connections. A reactive client called from a virtual thread102 is not inherently pointless; make one deliberate conversion at the boundary and avoid103 alternating `block`/resubscribe layers down the call graph.104- The models expose different diagnostic evidence. A thread-per-request dump often preserves105 a request stack; an asynchronous pipeline usually requires assembly checkpoints,106 correlation context, scheduler metrics and traces because no thread owns the request for107 its whole lifetime.108- Neither model changes the downstream. A connection pool of 20, a vendor quota of 600109 requests per minute, or a database that saturates at 4 000 IOPS bound both identically.110 A large measured improvement may remove a former bottleneck; distinguish useful completed111 throughput from shifted queues, dropped work and changed latency or correctness.112- A mixed codebase is acceptable; an _undocumented_ mixed codebase is not. Every endpoint113 should have a stated model, and the boundary between them should be one place where the114 handoff is explicit.115- Framework behaviour is not platform behaviour. `spring.threads.virtual.enabled`,116 `@RunOnVirtualThread` and Helidon's virtual-thread server are decisions those projects117 made; none of them is something "Java does". State which layer a claim belongs to.118- Do not benchmark the model. Benchmark the service, with the real dependencies, at the real119 concurrency, measuring useful throughput, tail latency, errors, retained memory and overload recovery.120- Changing threads does not propagate a transaction, security identity or Reactor Context121 automatically. State the context carrier and transaction owner at each asynchronous handoff;122 avoid sharing a persistence context across concurrent tasks.123124## Deliverable125126Record the chosen boundary and retained contracts, compatible runtime/configuration, active127and waiting limits with overflow behavior, evidence versus remaining hypotheses, and a load128or slow-consumer check that could reopen the choice. A small change needs only a short note.129130## References131132- [The comparison, dimension by dimension](references/decision-matrix.md) — the full matrix133 with an honest column for each model, memory arithmetic per in-flight request and per idle134 connection, backpressure sources, failure modes under overload, and the hybrid designs that135 work. Read when the decision is genuinely open, or when writing it up for a team.136- [Framework execution models](references/framework-execution-models.md) — exactly which137 Spring, Quarkus, Jakarta and Helidon settings put a request on which kind of thread, what138 each one silently unbounds, and how to verify at runtime which model a request actually139 ran under. Read before changing a framework flag or reviewing one.