Interviewer: Tell me about async/await?
A while ago, during a technical interview for a senior backend position, my mind went blank for a second. I almost blurted out the classic…
Interviewer: Tell me about async/await? I almost messed up! I didn’t know it could be used like that.

AI image
A while ago, during a technical interview for a senior backend position, my mind went blank for a second. I almost blurted out the classic, superficial answer: “Well, we use CompletableFuture chains, or we just write asynchronous code that reads like synchronous code using Virtual Threads." But as soon as the words left my mouth, I caught myself. That answer was way too shallow.
An interviewer doesn’t want you to recite framework definitions or tool names. What they want to see is that you understand the essence of asynchronous execution, know what exact architectural problems it solves, and are acutely aware of its boundaries, anti-patterns, and pitfalls.
Let’s dive deep into how Java solves the asynchronous readability problem, how it maps to the concepts of async/await, and how to avoid shooting yourself in the foot.
I. The Evolution: Why Do We Need “Async/Await” Styling?
Let’s state the core conclusion first: Writing asynchronous logic using synchronous syntax is all about reducing cognitive load and managing system resources.
To understand why modern Java structures asynchronous code the way it does, we have to look at the pain points of the past.
1. Callback Hell & Complex Chaining
In early asynchronous Java architectures (like older Netty or RxJava implementations), callbacks were the norm. The code was nested layer upon layer, resulting in highly unreadable structures:
// The historical nightmare of deep nesting
getData(a -> {
getMoreData(a, b -> {
getEvenMoreData(b, c -> {
getFinalData(c, result -> {
System.out.println(result);
});
});
});
});
2. The Functional Promise: CompletableFuture
Java 8 introduced CompletableFuture to fix this by enabling fluent, chained functional calls. It made code significantly cleaner:
getData()
.thenCompose(this::getMoreData)
.thenCompose(this::getEvenMoreData)
.thenCompose(this::getFinalData)
.thenAccept(System::println)
.exceptionally(err -> {
System.err.println("Error: " + err.getMessage());
return null;
});
This looks much better. But in complex enterprise development, you quickly discover that functional chaining has steep limitations:
- Conditional logic inside chains becomes highly convoluted.
- Passing intermediate variables down multiple layers requires messy data wrappers.
- Standard
try/catchblocks cannot catch exceptions thrown inside async stages; you must rely entirely on specialized exceptional handlers.
3. The Modern Solution: Virtual Threads & Structured Concurrency
With the arrival of Virtual Threads (Project Loom) in modern Java, we achieved the ultimate goal of the async/await paradigm: Writing highly performant, non-blocking asynchronous logic using straightforward, synchronous blocking syntax.
II. How to Write Modern Asynchronous Java
Let’s look at how we fetch a user profile asynchronously using Java’s standard tools.
import java.util.concurrent.CompletableFuture;
public class UserService {
// 1. A method returning a CompletableFuture behaves like an 'async' function
public CompletableFuture<String> fetchUser() {
return CompletableFuture.supplyAsync(() -> {
// Simulated network latency
return "User: Umesh";
});
}
public void executionExample() throws Exception {
// 2. Joining/Getting blocks the execution path locally, acting like 'await'
String user = fetchUser().join();
System.out.println(user);
}
}
Essential Rules of Java’s Async Ecosystem:
**CompletableFuture<T>is your Promise:** If a method returns a value asynchronously, it wraps it in a future container.**.join()and.get()are yourawaitkeywords:** They halt the sequential execution line until the asynchronous result is available.- Virtual Threads make “blocking” free: Historically, blocking a thread via
.join()was an expensive operation because platform threads map 1:1 to OS threads. With Virtual Threads, when a virtual thread blocks on a network call or a.join(), it is simply unmounted from the carrier thread, keeping your CPU completely saturated and efficient.
III. Serial vs. Parallel Execution: Parallel Optimization
Being able to use asynchronous primitives is not enough. The true test of a developer is knowing how to coordinate them without creating bottlenecks.
Scenario: Fetching Data from Three Independent Services
Suppose you need to call three microservices. Each takes 200ms to respond.
❌ The Snail Pace (Serial Bottleneck)
// Anti-pattern: Accidental sequential execution
String resA = fetchServiceA().join(); // Waits 200ms
String resB = fetchServiceB().join(); // Waits another 200ms
String resC = fetchServiceC().join(); // Waits another 200ms
// Total Time ≈ 600ms
One after another, your application crawls along.
The Correct Approach: Parallel Processing
By initiating the tasks simultaneously and combining their futures, we optimize the execution pipeline:
CompletableFuture<String> futureA = fetchServiceA();
CompletableFuture<String> futureB = fetchServiceB();
CompletableFuture<String> futureC = fetchServiceC();
// Combine them to run concurrently
CompletableFuture<Void> allFutures = CompletableFuture.allOf(futureA, futureB, futureC);
// Wait for all to complete
allFutures.join();
String result = futureA.join() + futureB.join() + futureC.join();
// Total Time ≈ 200ms
Using CompletableFuture.allOf(), all three requests are executed concurrently in the underlying thread pool.
IV. Handling Asynchronous Operations in Loops
Looping through async operations is where most production bugs crawl in.
❌ The Common Mistake: Unintentional Serialization
List<Integer> userIds = List.of(1, 2, 3, 4, 5);
for (int id : userIds) {
// Dangerous: This forces the loop to wait for each network call sequentially
String user = fetchUserNetworkCall(id).join();
process(user);
}
If you have 100 IDs and each takes 100ms, this loop runs for 10 seconds.
The Fix: Stream-Based Parallel Triggering
To achieve maximum performance, trigger the futures first to start processing them concurrently, collect them into a list, and then await their completion.
List<Integer> userIds = List.of(1, 2, 3, 4, 5);
// 1. Trigger all tasks instantly in parallel
List<CompletableFuture<String>> futures = userIds.stream()
.map(this::fetchUserNetworkCall)
.toList();
// 2. Wait for all of them to resolve and gather results
List<String> results = futures.stream()
.map(CompletableFuture::join)
.toList();
V. Three Pitfalls You Must Avoid
1. The Phantom Exception (Swallowing Failures)
If you do not explicitly handle exceptions inside an asynchronous block or fail to call .join()/.get() inside a robust structural context, exceptions can disappear into the void without crashing the thread or printing stack traces.
Solution: Always attach an exception handler or wrap your synchronization blocks in standard error catching mechanisms:
CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Database connection dropped!");
return "Data";
}).exceptionally(ex -> {
System.err.println("Recovered from error: " + ex.getMessage());
return "Fallback Data";
});
2. Thread Pool Starvation
By default, CompletableFuture.supplyAsync() uses the global ForkJoinPool.commonPool(). If someone writes heavy, blocking I/O operations inside the common pool, it starves the rest of your application components.
Solution: Always supply a dedicated, custom thread pool (Executor) for heavy I/O operations:
ExecutorService customIOExecutor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> {
// Your heavy database or HTTP call here
return downloadHeavyFile();
}, customIOExecutor);
3. Forgetting Timeouts
Asynchronous tasks depend on external networks. Leaving them without a timeout constraint is a recipe for memory leaks and hanging systems.
Solution: Use Java’s native timeout capabilities:
CompletableFuture<String> responseFuture = fetchRemoteConfig();
String result = responseFuture
.orTimeout(3, TimeUnit.SECONDS) // Automatically fails if it takes longer than 3s
.join();
VI. Summary
The next time an interviewer confronts you with an asynchronous architecture question, surprise them with your structural clarity:
“Asynchronous abstractions like
CompletableFutureand Virtual Threads give us the syntactic sugar to write clean, maintainable, synchronous-looking code. However, under the hood, they map to asynchronous execution abstractions. To write high-performance enterprise applications, we must deliberately handle parallel optimizations via coordination patterns, implement custom executor isolation to prevent starvation, and enforce explicit timeout limits."
Thank you for your patience in reading this article!
If you found this article helpful, please give it a clap 👏, and share it with your friends and follow me for more insights.
😊Your support is my biggest motivation to continue to output technical insights!
메타데이터
- post_id
- 5cd2e1f9299b
- slug
- interviewer-tell-me-about-async-await-5cd2e1f9299b
- url
- https://medium.com/codetutorials/interviewer-tell-me-about-async-await-5cd2e1f9299b
- canonical_url
- https://medium.com/codetutorials/interviewer-tell-me-about-async-await-5cd2e1f9299b
- author_url
- https://medium.com/@umeshcapg
- status
- ok
- fetched_at
- 2026-06-12 07:40:50