Java developers today have more power — and more choices — than ever before when it comes to building high-performance, concurrent applications. Two of the most widely discussed concurrency mechanisms are:

  • CompletableFuture (and traditional Futures)
  • Java 21's new Virtual Threads (part of Project Loom)

While both allow you to run tasks asynchronously or concurrently, they do so in fundamentally different ways. This blog aims to demystify both options, show how they work under the hood, and help you decide when to use which in real-world scenarios.


🧠 A Quick Primer

What is CompletableFuture?

Introduced in Java 8, CompletableFuture allows you to run asynchronous tasks without blocking the main thread. You can chain actions, handle exceptions, and combine multiple computations.

CompletableFuture.supplyAsync(() -> expensiveCall())
    .thenApply(result -> transform(result))
    .thenAccept(finalResult -> store(finalResult));

What are Virtual Threads?

Virtual Threads, introduced in Java 21, are lightweight, user-mode threads managed by the JVM. You can spin up millions of virtual threads cheaply, without tuning thread pools.

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> blockingCall());

Virtual threads allow you to write code that looks blocking (imperative), while still achieving massive concurrency.


⚙️ How They Work Internally

Feature CompletableFuture Virtual Threads
Scheduling ForkJoinPool or custom executor JVM-managed carrier threads
Style Callback-based Imperative/blocking style
Memory Usage Moderate (depends on pool size) Very low per thread (few KB)
Stack Handling Uses call stacks like normal threads Uses stack copying/yielding under the hood
Debuggability Poor (stack traces break with async chains) Great (preserves clean stack traces)

✅ When to Use CompletableFuture

✔ Best Fit Scenarios:

  • You are already using a reactive-style architecture
  • You need to chain multiple async computations
  • You want fine-grained composition of multiple parallel flows (e.g., allOf, anyOf)
  • You want timeouts, cancellation, and completion hooks

🔥 Example:

CompletableFuture<String> userData = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> orders = userData.thenCompose(user -> fetchOrders(user));

❌ Downsides:

  • Debugging is hard (stack traces get split across lambdas)
  • Exception handling is verbose
  • Doesn’t scale well if you try to avoid blocking with it

✅ When to Use Virtual Threads

✔ Best Fit Scenarios:

  • You are building I/O-heavy applications (HTTP, DB, file I/O)
  • You want thread-per-request design without scaling issues
  • You prefer writing clean, blocking-style logic
  • You are migrating from synchronous to scalable async

🔥 Example:

executor.submit(() -> {
    var user = fetchUser();
    var orders = fetchOrders(user);
    save(orders);
});

❌ Downsides:

  • Still maturing (some third-party libraries may not be virtual-thread-friendly)
  • CPU-bound workloads won’t benefit much
  • Needs JDK 21+, some tooling still catching up

🧪 Use Case Comparison

Web Server with 100,000 Connections

Criteria CompletableFuture Virtual Threads
Style Complex async chains One thread per request (clean)
Scalability High (but harder to write) Very high and easy
Stack trace Fragmented Clean
Exceptions Manual .exceptionally() chains Try-catch just works

Parallelizing Multiple Tasks

Scenario CompletableFuture Virtual Threads
1000 async DB calls Works well, needs chaining Works better, no chaining
CPU-intensive parallel tasks Same for both, no major gain

🧩 Complex Task Execution: 10 Tasks, Mixed Strategy

Let’s look at a concrete example involving 10 tasks, some sequential and some parallel:

🧭 Scenario

  • Group 1: T1 → T2 → T3 (sequential)
  • Group 2: T4, T5, T6, T7 (parallel)
  • Group 3: T8 → T9 → T10 (sequential)

✅ Virtual Threads Version

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

executor.submit(() -> {
    runTask("T1");
    runTask("T2");
    runTask("T3");
}).get();

Future> f4 = executor.submit(() -> runTask("T4"));
Future> f5 = executor.submit(() -> runTask("T5"));
Future> f6 = executor.submit(() -> runTask("T6"));
Future> f7 = executor.submit(() -> runTask("T7"));

f4.get(); f5.get(); f6.get(); f7.get();

executor.submit(() -> {
    runTask("T8");
    runTask("T9");
    runTask("T10");
}).get();

executor.shutdown();

CompletableFuture Version

CompletableFuture<Void> group1 = CompletableFuture.runAsync(() -> runTask("T1"))
    .thenRun(() -> runTask("T2"))
    .thenRun(() -> runTask("T3"));

group1.join();

CompletableFuture<Void> f4 = CompletableFuture.runAsync(() -> runTask("T4"));
CompletableFuture<Void> f5 = CompletableFuture.runAsync(() -> runTask("T5"));
CompletableFuture<Void> f6 = CompletableFuture.runAsync(() -> runTask("T6"));
CompletableFuture<Void> f7 = CompletableFuture.runAsync(() -> runTask("T7"));

CompletableFuture.allOf(f4, f5, f6, f7).join();

CompletableFuture<Void> group3 = CompletableFuture.runAsync(() -> runTask("T8"))
    .thenRun(() -> runTask("T9"))
    .thenRun(() -> runTask("T10"));

group3.join();

Note: Virtual threads give a natural, readable structure with try-catch and blocking behavior. CompletableFuture offers async composition but comes with verbosity and cognitive load.


🏆 TL;DR: When to Use What

Use Case Pick This
Highly composable flows CompletableFuture
I/O heavy services (DB, HTTP) Virtual Threads
Microservice backends Virtual Threads
Real-time dashboards, event-based UI CompletableFuture or reactive
Quick migrations from thread-per-request Virtual Threads

🔥 Bonus: Combine Both (Yes, Really)

You can still use CompletableFuture inside virtual threads for cases like:

  • Combining multiple tasks with .allOf()
  • Handling optional timeouts

But your main flow can stay clean, blocking, and idiomatic.

executor.submit(() -> {
    CompletableFuture<Void> future = CompletableFuture.allOf(
        CompletableFuture.runAsync(() -> loadA()),
        CompletableFuture.runAsync(() -> loadB())
    );
    future.get();
});

💬 Final Thoughts

Virtual Threads won’t kill CompletableFuture, but they do kill the reason you had to use it everywhere.

Write clean code. Let the JVM do the async gymnastics. Virtual threads are here to make concurrency boring again — and that’s a good thing.


Coming Next: A practical Spring Boot migration guide using Virtual Threads for REST APIs and DB calls. Stay tuned! 🚀