← Back to list

ExecutorService vs @Async vs CompletableFuture: The Thread, The Shortcut, and The Promise

1. Start simple — a restaurant analogy

Codio.dev · 2026-03-26 12:31 · 0 claps · 5.8 min read paywalled
#java #parallel-programming #multithreading #spring-boot #software-development
Open on Medium ↗
Wiki topics: 💻 · Programming 🍳 · Food & Cooking 📚 · Books & Reading

ExecutorService vs @Async vs CompletableFuture: The Thread, The Shortcut, and The Promise

1. Start simple — a restaurant analogy

Imagine you run a restaurant kitchen.

ExecutorService is the kitchen staff — you hire 5 cooks, assign them dishes, manage their shifts, and fire them when the restaurant closes. You control everything: how many cooks, what they do, when they stop.

@Async (Spring) is like putting a “rush order” sticker on a ticket. You don’t care which cook picks it up — you just slap the sticker on, and the kitchen system automatically assigns it to a free cook. Behind the scenes, there’s still kitchen staff (an ExecutorService), but you don’t manage them directly.

CompletableFuture is the order tracker screen — it tells you “Dish A is ready, now plate it, then send it to table 7, and if anything fails, send a replacement.” It’s about what happens after the cooking, not who does the cooking.

Here’s the root difference in one sentence: ExecutorService manages workers. @Async hides the workers. CompletableFuture choreographs the results.

Let me show you this visually.

Notice how they stack: CompletableFuture sits on top (orchestrating results), @Async is a middle shortcut (Spring framework magic), and ExecutorService is the foundation (actual thread pool management). They're not competitors — they're different layers of the same system.

2. Why do these exist? What problems do they solve?

Imagine your Java app needs to call three microservices — user service, inventory service, and pricing service. If you do it sequentially, each call takes 500ms, so the total is 1500ms. But these calls are independent — why not run them in parallel?

That’s the core problem: how do you run tasks concurrently in Java?

Each of our three tools was born to solve a different slice of this:

ExecutorService (Java 5, 2004) solved: “I need a pool of reusable threads instead of creating new Thread objects every time." Before this, developers manually created threads — expensive, error-prone, no reuse.

CompletableFuture (Java 8, 2014) solved: “I got the result back from a thread, but now I need to chain operations — transform it, combine it with another result, handle errors — all without blocking.” The old Future.get() was a brick wall: it blocked your thread until the result arrived.

@Async (Spring Framework) solved: “I don’t want to write any of this boilerplate. Just let me annotate a method and have Spring handle the threading.” It’s a productivity shortcut for Spring applications.

3. Step-by-step: How each one works internally

ExecutorService — the thread pool manager

Here’s what happens step by step:

  1. You create an ExecutorService with a fixed number of threads (say, 3).
  2. You submit() a task — it goes into an internal task queue.
  3. A free thread picks up the task and executes it.
  4. You get back a Future object — but calling future.get() blocks your thread until the result is ready. This is the old, painful way.

@Async — Spring’s magic sticker

With @Async, you never see the ExecutorService directly. Here's the hidden flow:

  1. You annotate a method with @Async.
  2. Spring creates a proxy around your bean — when your controller calls that method, it actually hits the proxy first.
  3. The proxy takes your method call and submits it to an ExecutorService (Spring Boot auto-configures one).
  4. The caller gets back immediately — no blocking. If the method returns CompletableFuture, the caller can chain operations on it later.

The key insight: @Async is just syntactic sugar over ExecutorService. It doesn’t introduce a new concurrency mechanism — it wraps the existing one.

CompletableFuture — the chain of promises

This is where it gets interesting. CompletableFuture isn't about running tasks — it's about composing what happens after tasks finish.

CompletableFuture gives you a pipeline: do this, then do that, then combine with this other thing, and if anything fails, recover gracefully. All without blocking a single thread.

4. Real-world systems — where each shines

Let me build an interactive comparison to make this concrete.

5. Going deeper — trade-offs and when NOT to use each

ExecutorService — when NOT to use: Don’t use it directly in a Spring Boot app just to run one async method. That’s what @Async is for. Using raw ExecutorService in Spring is like hand-cranking a car when it has an electric starter.

@Async — when NOT to use: Don’t use it for complex multi-step async pipelines. If you need to combine results from 3 services, handle partial failures, and apply timeouts, @Async alone can't express that — you'll end up with messy callback spaghetti. Also never use it for non-Spring applications.

CompletableFuture — when NOT to use: Don’t use it for simple fire-and-forget tasks where you don’t care about the result. A CompletableFuture you never join() or thenAccept() is a promise nobody reads — it's wasted complexity.

Here’s an important nuance: **CompletableFuture.supplyAsync() without a second argument uses ForkJoinPool.commonPool()**, which is shared across your entire JVM. If your async tasks are I/O-heavy (HTTP calls, DB queries), they'll starve CPU-bound tasks using the same pool. Always pass a dedicated ExecutorService for I/O work.

6. Common mistakes

Mistake 1: Calling @Async from within the same class. Spring's proxy can only intercept calls from outside the bean. this.fetchUserAsync() bypasses the proxy entirely — it runs synchronously. Solution: inject the bean into a different service and call it from there.

Mistake 2: Forgetting @EnableAsync in your Spring config. Without @EnableAsync on your @Configuration class, the @Async annotation does absolutely nothing. Your method runs on the calling thread, and you won't get an error — it just silently fails to be async.

Mistake 3: Using future.get() everywhere. Old-style Future.get() blocks the calling thread. If you're blocking the main thread waiting for an async result, you've defeated the purpose. Use CompletableFuture with thenApply/thenAccept instead.

Mistake 4: Never shutting down the ExecutorService. If you create a pool manually and don't call shutdown(), your JVM will not exit because the pool threads are still alive. In production, this causes zombie processes.

Mistake 5: Swallowing exceptions in CompletableFuture. If you don't add exceptionally() or handle(), exceptions are silently eaten. Your chain completes, but you never know it failed. Always add error handling at the end of every chain.

7. Summary

Here’s everything distilled:

  • ExecutorService is the thread pool itself — you create it, submit tasks, and shut it down. It’s the foundation layer. Use it when you need fine-grained control over thread count, queue strategy, and lifecycle.
  • @Async is Spring’s decorator that hides the ExecutorService behind an annotation. Use it for simple fire-and-forget or single-method async in Spring apps. Under the hood, it’s just submitting to an executor via a proxy.
  • CompletableFuture is the result composition API — chaining, combining, error handling, all non-blocking. Use it when you need to orchestrate multiple async operations and their results. It works with any executor (or the default ForkJoinPool).
  • The root difference: ExecutorService = who runs the work. @Async = don’t make me think about who runs it. CompletableFuture = what happens after the work finishes.

One-line intuition to remember: ExecutorService is the kitchen staff, @Async is the “rush order” sticker, CompletableFuture is the recipe that says “after the pasta is done, plate it, add sauce, and if the sauce burns, use backup sauce.”

8. Conclusion

You now understand the three layers of Java’s async world — from the manual thread pool all the way up to elegant composition pipelines. The real power comes from combining them: use a custom ExecutorService for pool tuning, pass it into CompletableFuture.supplyAsync() for composition, and if you're in Spring, let @Async handle the simple cases.

THANKS FOR READING


메타데이터
post_id
81e9cd66bdcd
slug
executorservice-vs-async-vs-completablefuture-the-thread-the-shortcut-and-the-promise-81e9cd66bdcd
url
https://medium.com/@Codio.dev/executorservice-vs-async-vs-completablefuture-the-thread-the-shortcut-and-the-promise-81e9cd66bdcd
canonical_url
https://medium.com/@Codio.dev/executorservice-vs-async-vs-completablefuture-the-thread-the-shortcut-and-the-promise-81e9cd66bdcd
author_url
https://medium.com/@Codio.dev
status
ok
fetched_at
2026-07-24 19:22:03