Over Two Decades of Java Concurrency, and It’s Still a Minefield

📌 Note: This article was published on Medium and is cross-posted here for completeness.

From raw threads in 1996 to java.util.concurrent (2004), lambdas (2014), reactive streams, and virtual threads (2023), Java has shipped tool after tool. Yet writing correct concurrent code that has clear lifetimes, reliable cancellation, and predictable error handling; still resembles a minefield. Here’s why, and how we can do better with modern patterns.

Why is this still so hard?


The Problem: Too Many Half-Solutions

Let’s take a simple case:

Fetch data from three APIs concurrently, process results, handle errors, and respect a timeout.

1. Threads (1995)

public List<String> fetchData() {
    List<String> results = Collections.synchronizedList(new ArrayList<>());
    CountDownLatch latch = new CountDownLatch(3);

    Thread t1 = new Thread(() -> {
        try {
            results.add(callApi("api1"));
        } catch (Exception e) {
            // What do we do here?
        } finally {
            latch.countDown();
        }
    });

    // … repeat for t2, t3 …

    t1.start(); t2.start(); t3.start();

    try {
        latch.await(10, TimeUnit.SECONDS); // What if it times out?
    } catch (InterruptedException e) {
        // Now what? Cancel the threads?
    }

    return results; // Hope for the best
}

Problems:

Manual lifecycle management

No consistent error propagation

Cancellation is basically impossible

2. ExecutorService (2004)

public List&lt;String> fetchData() throws Exception {
    ExecutorService executor = Executors.newFixedThreadPool(3);
    try {
        List&lt;Future&lt;String>> futures = List.of(
            executor.submit(() -> callApi("api1")),
            executor.submit(() -> callApi("api2")),
            executor.submit(() -> callApi("api3"))
        );

        List&lt;String> results = new ArrayList&lt;>();
        for (Future&lt;String> f : futures) {
            try {
                results.add(f.get(10, TimeUnit.SECONDS));
            } catch (TimeoutException e) {
                f.cancel(true);
            }
        }
        return results;
    } finally {
        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);
        executor.shutdownNow(); // Fingers crossed
    }
}

Better, but still verbose and error-prone.

3. CompletableFuture (2014)

public CompletableFuture<List<String>> fetchData() {
    var f1 = CompletableFuture.supplyAsync(() -> callApi("api1"));
    var f2 = CompletableFuture.supplyAsync(() -> callApi("api2"));
    var f3 = CompletableFuture.supplyAsync(() -> callApi("api3"));

    return CompletableFuture.allOf(f1, f2, f3)
        .thenApply(v -> List.of(f1.join(), f2.join(), f3.join()))
        .orTimeout(10, TimeUnit.SECONDS)
        .exceptionally(ex -> List.of());
}

Cleaner, but:

No structured concurrency

Cancellation is awkward

Error handling is ad-hoc

4. Virtual Threads (2023)

public List<String> fetchData() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var futures = List.of(
            executor.submit(() -> callApi("api1")),
            executor.submit(() -> callApi("api2")),
            executor.submit(() -> callApi("api3"))
        );

        return futures.stream()
            .map(f -> {
                try {
                    return f.get(10, TimeUnit.SECONDS);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            })
            .toList();
    }
}

Virtual threads help performance, but the core problems remain:

No automatic cancellation

No clear error boundaries

Timeouts are still manual

What’s Wrong Here?

After 20+ years, Java’s concurrency is still missing:

Structured Concurrency — no parent/child lifetimes, leading to leaks.

Reliable Cancellation — interruption is unreliable and inconsistent.

Consistent Error Handling — failures don’t cleanly propagate.

Resource Safety — Executors and threads must be closed manually.

Context — no standard way to pass cancellation tokens, timeouts, or tracing.

Other languages got this right:

Go has context.WithTimeout for group cancellation.

Kotlin has coroutines with structured scopes.

C# has Tasks with CancellationToken.

What We Actually Want

try (var scope = new CoroutineScope()) {
    var results = List.of("api1", "api2", "api3").stream()
        .map(api -> scope.async(suspend -> callApi(suspend, api)))
        .map(handle -> handle.join())
        .toList();
    return results;
} // Automatic cleanup, cancellation, and error propagation

This is:

Structured — parent/child relationships are explicit

Cancellable — cooperative and consistent

Safe — resources cleaned up automatically

Transparent — errors bubble naturally

Enter JCoroutines 🚀

Concurrency should feel like an elevator: press a few clear buttons and trust the machinery.

That’s what I set out to build with JCoroutines:

Structured concurrency by default

Explicit context passing (cancellation, timeouts, schedulers)

No compiler magic — just clean Java APIs on top of virtual threads

It’s small, explicit, and available today.

Try It Out
On Maven Central:

maven xml

<dependency>
  <groupId>tech.robd</groupId>
  <artifactId>jcoroutines</artifactId>
  <version>0.1.0</version>
</dependency>

Or Gradle:

kotlin gradle.build.kts

implementation("tech.robd:jcoroutines:0.1.0")

The Path Forward
Java itself is heading this way (see JEP 428 on structured concurrency), but it will take years before that’s fully stable.

Meanwhile, JCoroutines gives you these patterns now — using just Java 21+ and virtual threads.

📦 Maven Central

💻 GitHub repo

Scroll to Top