Compare CompletableFuture, Flow, virtual threads, scoped values, and structured concurrency to choose the right model for modern Java work.
Modern Java concurrency is no longer a choice between raw threads and callback-heavy async code. Since Java 8, the platform has added several layers of concurrency support, each aimed at a different problem:
CompletableFuture for asynchronous compositionFlow for reactive streams with backpressureThe important design question is not which API is newest. It is which model matches the shape of the work.
Older Java often forced teams into one of two awkward extremes:
The Loom-era features in JDK 21 through JDK 26 change that trade-off. As of April 15, 2026:
That means modern Java concurrency is now partly about runtime scalability and partly about making task lifetime easier to reason about.
| Tool | Best For | Main Strength | Main Risk |
|---|---|---|---|
CompletableFuture | independent async steps and fan-out/fan-in flows | good composition without blocking callers | pipelines become hard to debug when business logic sprawls across callbacks |
Flow and reactive libraries | event streams and backpressure-aware pipelines | explicit demand management | cognitive overhead when the workload is not naturally stream-oriented |
| virtual threads | high-concurrency blocking I/O | simple request-style code at much higher scale | teams forget that database, HTTP, and downstream capacity still limit throughput |
| scoped values | request-scoped context passed across concurrent work | explicit lifetime and safer sharing than ThreadLocal | hidden inputs if overused instead of passing important data explicitly |
| structured concurrency | related subtasks that should succeed or fail together | cancellation and error propagation follow task lifetime | preview API status means teams must accept release-gated adoption |
CompletableFuture Is Still UsefulVirtual threads did not make CompletableFuture obsolete. CompletableFuture still fits when:
It is still a good tool for parallel lookup, aggregation, and non-blocking service orchestration:
1CompletableFuture<UserProfile> profileFuture =
2 CompletableFuture.supplyAsync(() -> profileClient.fetch(userId), executor);
3
4CompletableFuture<List<OrderSummary>> ordersFuture =
5 CompletableFuture.supplyAsync(() -> orderClient.fetchRecent(userId), executor);
6
7Dashboard dashboard = profileFuture.thenCombine(
8 ordersFuture,
9 Dashboard::new
10).join();
The review question is whether the pipeline is clearer than a request-style implementation. If the answer is no, virtual threads may be the better fit.
Virtual threads are lightweight threads scheduled by the JVM rather than mapped one-to-one onto operating system threads. They are a strong fit for server code that spends most of its time waiting on:
That lets teams keep straightforward blocking code while serving far more concurrent tasks:
1try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
2 Future<UserProfile> profile = executor.submit(() -> profileClient.fetch(userId));
3 Future<List<OrderSummary>> orders = executor.submit(() -> orderClient.fetchRecent(userId));
4
5 Dashboard dashboard = new Dashboard(profile.get(), orders.get());
6 render(dashboard);
7}
This is simpler than pushing the same workflow through a large async callback graph. The trade-off is that simplicity at the Java layer does not remove bottlenecks in connection pools, rate limits, or downstream latency.
Flow Still Matters When Backpressure Is The ProblemReactive streams exist for a different reason than virtual threads. They are about controlled data flow under load, not just cheap waiting. If a producer can outpace a consumer, backpressure is the real design issue.
That is why Flow and libraries such as Reactor remain relevant in:
If the workload is request-response with blocking I/O, virtual threads are often simpler. If the workload is an unbounded stream where demand management matters, reactive patterns still earn their complexity.
Virtual threads solve scalability, but they do not by themselves solve task lifetime and context-sharing problems. Two newer APIs address those issues directly.
Scoped values let a caller provide read-only contextual data to code that runs within a dynamic scope. They are especially useful for:
They are often safer than ThreadLocal because the lifetime is explicit in code rather than attached to the thread indefinitely.
Structured concurrency treats related concurrent tasks as one unit. That improves:
The API is still preview as of JDK 26, so adopting it means accepting preview flags and release-specific review.
Use this decision order:
Flow or a reactive library.CompletableFuture may still be the cleanest tool.ThreadLocal state.ThreadLocal when scope-bounded context is the real need.Modern Java concurrency is better understood as a toolbox than as a single paradigm. CompletableFuture, Flow, virtual threads, scoped values, and structured concurrency each solve a different class of problem. Good design comes from matching the model to the workload rather than treating every concurrent system as a generic async pipeline.