CompletableFuture vs Future, supplyAsync vs runAsync, chaining (thenApply/thenCompose/thenCombine), exceptions in chains, combining many futures, advanced features (timeouts, executors, delayed execution), when to avoid CompletableFuture, parallelStream() and its drawbacks, concurrency vs parallelism, blocking vs non-blocking I/O, NIO selectors, the Netty event loop and the Reactive Streams specification.
Published September 25, 2026
Asynchronous questions test whether you know:
Tie the answers to how virtual threads change the calculation: much of what people used CompletableFuture and reactive code for can now be written as simple blocking code on virtual threads.
CompletableFuture and Future?Short answer:
Future (Java 5) is a read-only handle to a result. You can only get() (blocking), isDone() and cancel(). There's no way to attach a callback, combine futures, or complete one manually.CompletableFuture (Java 8) is a Future plus a CompletionStage:
thenApply, thenCompose, thenCombine, allOf/anyOf);exceptionally, handle, whenComplete);complete, completeExceptionally), which bridges callback APIs;Learn it in depth → CompletableFuture
supplyAsync() differ from runAsync()?Short answer:
supplyAsync(Supplier<T>) runs a task that returns a value, giving CompletableFuture<T>.runAsync(Runnable) runs a task with no result, giving CompletableFuture<Void>.Both run on ForkJoinPool.commonPool() unless you pass an Executor as the second argument. Always pass one for I/O-bound work. A virtual-thread executor is ideal.
ExecutorService io = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<Customer> customer = CompletableFuture.supplyAsync(() -> customerClient.get(id), io);
CompletableFuture<Void> audit = CompletableFuture.runAsync(() -> auditLog.write(event), io);
CompletableFutures?Short answer:
thenApply(fn): transform the result, synchronously (like map).thenCompose(fn): the next step returns another future (like flatMap). It avoids CompletableFuture<CompletableFuture<T>>.thenCombine(other, fn): combine two independent futures when both complete.thenAccept/thenRun: consume the result, or run afterwards.*Async variants run the stage on an executor. The non-async variants run on whichever thread completed the previous stage, or the calling thread if it's already complete.CompletableFuture<Quote> quote =
CompletableFuture.supplyAsync(() -> catalog.find(sku), io) // Product
.thenCompose(p -> CompletableFuture.supplyAsync(() -> pricing.price(p), io)) // async step returning a future
.thenCombine(CompletableFuture.supplyAsync(() -> tax.rateFor(region), io),
(price, rate) -> new Quote(price, price.multiply(rate)))
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Quote.unavailable(sku));
CompletableFuture chain?Short answer: The failing stage completes exceptionally, and the exception propagates down the chain. The dependent stages are skipped (their functions don't run), and each completes exceptionally with a CompletionException wrapping the cause. It stops at the first handler:
exceptionally(fn): recover with a fallback value (it runs only on failure).handle((result, ex) -> ...): always runs, and can map both success and failure.whenComplete((result, ex) -> ...): a side effect (logging) that doesn't change the outcome.exceptionallyCompose (an asynchronous fallback).If nobody handles it and nobody calls join/get, the error is silently lost. join() throws CompletionException, and get() throws ExecutionException: unwrap getCause() before logging or classifying.
CompletableFutures, and wait for all of them to finish?Short answer: Use CompletableFuture.allOf(futures...), which completes when all complete (exceptionally if any failed). Then collect the results with join() (non-blocking at that point). anyOf completes with the first one to finish.
List<CompletableFuture<Price>> futures = skus.stream()
.map(sku -> CompletableFuture.supplyAsync(() -> pricing.price(sku), io))
.toList();
CompletableFuture<List<Price>> all = CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList());
Key points to cover:
allOf doesn't cancel the others when one fails. Handle partial failures per future (handle), or cancel explicitly.StructuredTaskScope, preview in recent JDKs), fan-out with automatic cancellation on failure becomes simpler: ShutdownOnFailure semantics.CompletableFuture?Short answer:
orTimeout(t, unit) fails with TimeoutException, and completeOnTimeout(value, t, unit) falls back to a default.CompletableFuture.delayedExecutor(delay, unit[, executor]), for retries with back-off.*Async(fn, executor) methods).new CompletableFuture<>() plus complete(...) from a callback API. failedFuture/completedFuture/completedStage.exceptionallyCompose/exceptionallyAsync (Java 12).copy()/minimalCompletionStage(), for defensive APIs (Java 9).state(), resultNow(), exceptionNow().cancel() completes the future with CancellationException, but doesn't interrupt the running task. CompletableFuture has no link to the thread doing the work.CompletableFuture?Short answer:
cancel() doesn't interrupt. Use executors plus Future.cancel(true), or structured concurrency.parallelStream(), and what are its main drawbacks?Short answer: collection.parallelStream() (or .parallel()) runs the pipeline on the common ForkJoinPool: the source is split with a Spliterator, the chunks are processed concurrently, and the partial results are combined. Its drawbacks:
LinkedList, Stream.iterate and I/O-backed streams split badly.forEachOrdered, limit/skip on ordered streams, and sorted reduce parallelism. findAny and unordered operations are cheaper.long primes = LongStream.rangeClosed(2, 50_000_000).parallel().filter(Primes::isPrime).count(); // a good fit: big, CPU-bound, splittable
Learn it in depth → Streams API
Short answer:
The Java tools:
CompletableFuture, reactive code, executors;Arrays.parallelSort, and the Vector API.As Rob Pike put it, concurrency is a way to structure a program. Parallelism is one possible way to execute it.
Short answer:
socket.read() or a JDBC query suspends the calling thread until data arrives. It's simple to write, but with platform threads each waiting connection costs a thread (about 1 MB of stack, and context switches), so scalability is limited by thread count.Selector?Short answer: A java.nio.channels.Selector lets one thread monitor many non-blocking channels (sockets) for readiness events (OP_ACCEPT, OP_CONNECT, OP_READ, OP_WRITE). It's built on the OS's multiplexing facility (epoll on Linux, kqueue on macOS/BSD). The loop:
select(), which blocks until at least one channel is ready;This reactor pattern is the core of Netty, Tomcat's NIO connector, and Jetty.
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
server.configureBlocking(false).register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
for (Iterator<SelectionKey> it = selector.selectedKeys().iterator(); it.hasNext(); ) {
SelectionKey key = it.next(); it.remove();
if (key.isAcceptable()) server.accept().configureBlocking(false).register(selector, SelectionKey.OP_READ);
else if (key.isReadable()) handleRead((SocketChannel) key.channel());
}
}
Short answer:
EventLoopGroups. Typically a small boss group accepts connections, and a worker group (about 2 × cores) handles I/O.EventLoop is a single thread running a loop with a Selector (or native epoll/io_uring transports). It processes I/O readiness events for its assigned channels, plus queued tasks and scheduled tasks.ChannelPipeline of handlers (decoders, business handlers, encoders), using pooled, reference-counted ByteBufs (often direct memory).The golden rule: never block an event loop thread. A blocking call stalls every connection on that loop. Offload blocking work to a separate executor, or to virtual threads. Spring WebFlux and Reactor Netty run on this model, which is why a blocking JDBC call inside a WebFlux handler is so harmful.
Short answer: A small standard for asynchronous stream processing with non-blocking back-pressure. It has four interfaces, Publisher, Subscriber, Subscription and Processor, which are also in the JDK as java.util.concurrent.Flow (Java 9), plus rules and a TCK. The protocol is:
subscribe → onSubscribe(subscription);request(n);onNext signals;onComplete or onError.It's implemented by Project Reactor (Mono/Flux), RxJava, Akka Streams, the MongoDB and R2DBC drivers, and reactive Kafka. Interoperability means a Reactor Flux can consume from any compliant publisher.
Learn it in depth → Event-Driven Architecture Patterns
CompletableFuture chaining? (The rapid-fire version)Short answer: Building a pipeline of dependent asynchronous steps, where each stage runs when the previous one completes:
thenApply: transform;thenCompose: an asynchronous next step;thenCombine: join two futures;thenAccept/thenRun: side effects;exceptionally/handle: recovery.Pass executors for blocking stages, add orTimeout, and terminate the chain with a handler, so failures are never lost.
Q: Which thread runs thenApply if the future is already complete?
A: The thread that calls thenApply runs the function immediately. If the future isn't complete yet, the function runs on the thread that completes it. That's why heavy work belongs in thenApplyAsync(fn, executor).
Q: How do you propagate MDC and tracing context through CompletableFuture?
A: Wrap the executors with context-propagating decorators: Micrometer's ContextExecutorService/ContextSnapshot, Spring's TaskDecorator, or OpenTelemetry's Context.taskWrapping. Otherwise the logs lose their trace IDs after the first async hop.
Q: Why does a parallel stream inside a ForkJoinPool.submit() use that pool?
A: Because ForkJoin tasks forked from a worker thread run in the pool that thread belongs to. It's an implementation detail people use to isolate parallel streams. It works, but isn't specified behaviour.
Q: Is WebFlux faster than Spring MVC? A: Not per request. It scales better for high concurrency with slow I/O, using fewer threads. With virtual threads, Spring MVC reaches similar scalability for most workloads, with simpler code. Choose WebFlux for streaming, back-pressure, or when you're already reactive end-to-end.