> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Async Stack Traces in Java: CompletableFuture and Executors
- URL: https://www.ggorantala.dev/java-async-stack-traces/
- Published: 2026-09-10T00:56:00.000Z
- Updated: 2026-11-01T01:57:57.000Z
- Description: A CompletableFuture stack trace points at a pool worker and says nothing about who submitted the task. How to read async Java traces and get that context back.
- Author: Gopi Gorantala
- Tags: stack-traces, debugging, java-concurrency

```log
Exception in thread "pool-1-thread-1" java.lang.IllegalArgumentException: empty body for evt_1
	at Lab.sign(Lab.java:8)
	at Lab.handle(Lab.java:13)
	at Lab.lambda$bare$0(Lab.java:18)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)

```

Nobody in this trace knows who asked for the work. That's the whole problem with async traces, and the rest of this post is about living with it.

I got this on OpenJDK 21.0.12.1 (Ubuntu build, Linux, plain `java` from a terminal). It's a webhook handler that signs incoming events on a thread pool, and the event had an empty body. If you landed here searching for a **CompletableFuture stack trace** explanation, the executor version above is the simplest shape of the same story, so we start there and work up to `CompletionException`. Frame anatomy itself is in [how to read a Java stack trace](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev); I'm assuming you know what `at java.base/...` means.

## Whose thread is this, and who called it?

My first read was wrong in a boring way. I looked at `Lab.handle` on line 13, then `Lab.lambda$bare$0` on line 18, and went hunting for the caller of `handle` in the trace. There isn't one. The bottom of this trace isn't `main`. It's `Thread.run`, because the thread that threw is `pool-1-thread-1`, and its entire life is `Thread.run` → `Worker.run` → `runWorker` → your task. The thread that called `execute()` is `main`, it's somewhere else entirely, and nothing in this stack refers to it.

So the short read is: the top three frames are yours, the bottom three are the pool, and the link between "who submitted this" and "who runs this" doesn't exist in the data. A stack is per thread. It was never going to have the submitter in it. (If you're on virtual threads, the shape shifts again; that's [its own post](https://ggorantala.dev/virtual-thread-stack-traces/?ref=ggorantala.dev).)

## Throw it from a pool on purpose

Pin the JDK. I used OpenJDK 21.0.12.1; a pinned `eclipse-temurin:21` image gives you the same shape. Save this as `Lab.java` and keep the blank lines, because every line number in this post depends on them.

```java
import java.util.concurrent.*;
import java.util.function.*;

public class Lab {
    record Webhook(String id, String body) {}

    static String sign(Webhook w) {
        if (w.body().isEmpty()) throw new IllegalArgumentException("empty body for " + w.id());
        return w.id() + ":" + w.body().length();
    }

    static void handle(Webhook w) {
        System.out.println("signed " + sign(w));
    }

    static void bare() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(1);
        pool.execute(() -> handle(new Webhook("evt_1", "")));
        pool.shutdown();
        pool.awaitTermination(2, TimeUnit.SECONDS);
    }

    static void submitSwallowed() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(1);
        pool.submit(() -> handle(new Webhook("evt_2", "")));
        pool.shutdown();
        pool.awaitTermination(2, TimeUnit.SECONDS);
        System.out.println("done, nothing printed above");
    }

    static void submitGet() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(1);
        Future<?> f = pool.submit(() -> handle(new Webhook("evt_3", "")));
        try {
            f.get();
        } finally {
            pool.shutdown();
        }
    }

    static void cfJoin() {
        CompletableFuture.supplyAsync(() -> sign(new Webhook("evt_4", ""))).join();
    }

    static void cfChain() {
        CompletableFuture<String> f = CompletableFuture
            .supplyAsync(() -> sign(new Webhook("evt_5", "")))
            .thenApply(String::toUpperCase)
            .exceptionally(ex -> {
                System.out.println("handler got: " + ex.getClass().getName());
                throw new IllegalStateException("retry exhausted", ex);
            });
        f.join();
    }

    static void cfFailingStage() {
        CompletableFuture.supplyAsync(() -> sign(new Webhook("evt_6", "")))
            .exceptionally(ex -> { System.out.println("same stage got: " + ex.getClass().getName()); return "x"; })
            .join();
        CompletableFuture.supplyAsync(() -> sign(new Webhook("evt_6", "")))
            .thenApply(s -> s)
            .exceptionally(ex -> { System.out.println("next stage got: " + ex.getClass().getName()); return "x"; })
            .join();
    }

    static Runnable tagged(Runnable r) {
        Exception site = new Exception("submitted from " + Thread.currentThread().getName());
        return () -> {
            try { r.run(); }
            catch (RuntimeException e) { e.addSuppressed(site); throw e; }
        };
    }

    static void tag() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(1);
        Future<?> f = pool.submit(tagged(() -> handle(new Webhook("evt_7", ""))));
        try { f.get(); } catch (ExecutionException e) { e.getCause().printStackTrace(); }
        finally { pool.shutdown(); }
    }

    static void handler() throws Exception {
        ThreadFactory tf = r -> {
            Thread t = new Thread(r, "webhook-worker-1");
            t.setUncaughtExceptionHandler((th, ex) ->
                System.out.println("handler on " + th.getName() + ": " + ex));
            return t;
        };
        ExecutorService pool = Executors.newFixedThreadPool(1, tf);
        pool.execute(() -> handle(new Webhook("evt_8", "")));
        Future<?> f = pool.submit(() -> handle(new Webhook("evt_8", "")));
        pool.shutdown();
        pool.awaitTermination(2, TimeUnit.SECONDS);
        System.out.println("submit() reached handler? no; future isDone=" + f.isDone());
    }

    public static void main(String[] args) throws Exception {
        switch (args[0]) {
            case "bare" -> bare();
            case "swallow" -> submitSwallowed();
            case "get" -> submitGet();
            case "join" -> cfJoin();
            case "chain" -> cfChain();
            case "stage" -> cfFailingStage();
            case "tag" -> tag();
            case "handler" -> handler();
        }
    }
}

```

Compile with debug info and run one scenario at a time. `-g` is the default for line numbers anyway, but I like being explicit when I'm about to quote line numbers at you.

```bash
javac -g Lab.java
java Lab bare       # execute(): worker prints it, main never sees it
java Lab swallow    # submit(): nothing printed at all
java Lab get        # submit() + Future.get(): ExecutionException
java Lab join       # supplyAsync + join(): CompletionException
java Lab chain      # thenApply + exceptionally that rethrows
java Lab stage      # what exceptionally() actually receives
java Lab tag        # submit-site captured as Suppressed
java Lab handler    # UncaughtExceptionHandler on the thread factory

```

Variations worth running: `java -Djava.util.concurrent.ForkJoinPool.common.parallelism=3 Lab join`, and the same lab on a bigger machine. More on why in a minute. If your `join` trace ends at `ForkJoinWorkerThread.run` and mine ends at `Thread.run`, you aren't wrong, we have different core counts.

## Four ways to get a CompletableFuture stack trace (and its executor cousins)

**`execute()`, which printed the trace at the top of this post.** The pool thread has no handler of its own, so the default one prints `Exception in thread "pool-1-thread-1"` to stderr. The task is dead and so is that worker thread. I didn't test what the pool does next, but `ThreadPoolExecutor` is built to replace a worker that dies. You see one trace and your app keeps running, which is exactly why people miss it.

**`submit()` with nobody calling `get()`.** Same code, one word different, and the output was:

```log
done, nothing printed above

```

That's the whole output. The exception is sitting inside the `FutureTask`, the pool thread is fine, no log line exists. I'd call this the most expensive async bug in Java, because it looks like success.

**`submit()` plus `Future.get()`.** Now you get something to read:

```log
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.IllegalArgumentException: empty body for evt_3
	at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
	at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191)
	at Lab.submitGet(Lab.java:35)
	at Lab.main(Lab.java:100)
Caused by: java.lang.IllegalArgumentException: empty body for evt_3
	at Lab.sign(Lab.java:8)
	at Lab.handle(Lab.java:13)
	at Lab.lambda$submitGet$2(Lab.java:33)
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)

```

Two stacks in one trace, and they belong to two different threads. The outer block (`submitGet` line 35, `main` line 100) is the caller who *asked* for the result. The `Caused by` block is the worker who *produced* the failure. Line 33, the lambda, is where the task was submitted from; line 35 is where the caller blocked on it. Different lines, same method, easy to blur together.

**`supplyAsync(...).join()`.** This is the one that surprised me:

```log
Exception in thread "main" java.util.concurrent.CompletionException: java.lang.IllegalArgumentException: empty body for evt_4
	at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:315)
	at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:320)
	at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1770)
	at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.IllegalArgumentException: empty body for evt_4
	at Lab.sign(Lab.java:8)
	at Lab.lambda$cfJoin$3(Lab.java:42)
	at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1768)
	... 1 more

```

The header says `Exception in thread "main"`. The frames under it say `Thread.run`. There's no `Lab.cfJoin` and no `Lab.main` anywhere. The `CompletionException` was *created on the worker thread*, inside `encodeThrowable`, so its stack is the worker's stack, and `join()` rethrew that same object on main. Remember that frames are captured when a `Throwable` is constructed, not when it's thrown ([the hub post](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev) has the demo). It's the same rule, showing up in a place where it looks like a bug.

The `... 1 more` is the usual elision from [the Caused by post](https://ggorantala.dev/java-caused-by-root-cause/?ref=ggorantala.dev), and the frame it hides is `Thread.run`, not `AsyncSupply.run`. The printer compares the two stacks from the bottom up and stops at the first frame that differs. `AsyncSupply.run` shows up in both blocks, but at line 1768 ("called your supplier") in one and line 1770 ("caught what it threw") in the other, so the comparison stops there and only `Thread.run` is shared. I first read that as `AsyncSupply.run` being the elided frame, and I was wrong. Nothing is lost either way. (With a bigger common pool the same trace ends `... 6 more`, because six frames line up before they differ.)

## Every frame, annotated

Taking the `get` trace, because it has both halves:

```log
Exception in thread "main" ...ExecutionException: ...IllegalArgumentException: empty body for evt_3   (1)
	at ...FutureTask.report(FutureTask.java:122)                                                       (2)
	at ...FutureTask.get(FutureTask.java:191)                                                          (3)
	at Lab.submitGet(Lab.java:35)                                                                      (4)
	at Lab.main(Lab.java:100)                                                                          (5)
Caused by: ...IllegalArgumentException: empty body for evt_3                                           (6)
	at Lab.sign(Lab.java:8)                                                                            (7)
	at Lab.lambda$submitGet$2(Lab.java:33)                                                             (8)
	at ...Executors$RunnableAdapter.call(Executors.java:572)                                           (9)
	at ...FutureTask.run(FutureTask.java:317)                                                          (10)
	at ...ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)                                   (11)

```

1. The thread named here is the *caller's*. `ExecutionException` is a checked wrapper; its message is the cause's `toString()`, which is why the same text appears twice.
2. `report` is where the stored exception is wrapped. This frame is JDK plumbing and you can skip it.
3. `get` is the blocking call. Interesting only because it tells you the caller waited.
4. Your code, the consumer side. This is *not* where the bug is.
5. Bottom of the caller's stack, `main`.
6. Switch of threads. Everything below this line ran on `pool-1-thread-1`, and the trace doesn't say so. The thread name isn't printed in a `Caused by` block. You have to infer it from the bottom frames.
7. The real culprit: `sign`, line 8, an empty-body check.
8. The task body. `lambda$submitGet$2` is a [synthetic name](https://ggorantala.dev/java-stack-trace-strange-frames/?ref=ggorantala.dev); the `2` is a class-wide counter, not a count of anything in this method.
9. `RunnableAdapter` is `submit(Runnable)` turning your lambda into a `Callable`. Skip.
10. `FutureTask.run` catches your exception and stores it. This frame is why `submit()` swallows things.
11. The worker loop. Everything under it is the pool.

Open frame 7 first, frame 8 second, and don't open anything between 1 and 5 until you care *why* someone was waiting.

## Why the submitter's stack is gone

The first half of this section was a dead end for me. I assumed the JVM or the executor must be stashing the submitting thread's stack somewhere I could ask for, a "task origin" the way a debugger shows async stack traces. As far as I can tell it doesn't: nothing in `ThreadPoolExecutor` or `FutureTask` that I looked at in the Javadoc records who called `submit`. IDE debuggers can show an "async stack trace", and as I understand it that's the debugger instrumenting `submit`/`execute` to capture one on purpose. Tooling, not the runtime, and it won't be in your production logs.

What the runtime does have is the rule from the hub post: `Throwable.fillInStackTrace()` runs in the constructor, and records the *current thread's* frames at that moment. So the async story is a story about which constructor ran on which thread:

```log
main thread                         pool worker thread
-----------                         ------------------
submit(task) ------ hands off ----> run task
get()/join()                        task throws  X        (X's frames: worker's)
   ^                                CF wraps in CompletionException   (frames: worker's)
   |                                FutureTask stores X
ExecutionException created HERE <-- report(): new ExecutionException(X)  (frames: caller's)

```

Two details from `CompletableFuture` that I checked against the JDK 21 source (read through a summarising fetch of `CompletableFuture.java` at `jdk-21+35`, so treat the second one as paraphrase, not quote). First, `encodeThrowable` wraps anything that isn't already a `CompletionException`, which is why stages downstream see the wrapper. Second, the async default executor is `ForkJoinPool.commonPool()` only when the common pool's parallelism is above 1; otherwise `CompletableFuture` falls back to a thread-per-task executor.

That second one explains the trace I got. My box has two cores, so common-pool parallelism is 1 and `supplyAsync` started a fresh `Thread` per task, hence `Thread.run` at the bottom. With `-Djava.util.concurrent.ForkJoinPool.common.parallelism=3` the same lab ends like this:

```log
	at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1770)
	at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.exec(CompletableFuture.java:1760)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
	at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
	at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
	at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)

```

(That's the `CompletionException` block only; the `Caused by` block below it ends `... 6 more`.) Your local laptop with eight cores and a one-vCPU container in Kubernetes will print different bottoms for the same code. Worth knowing before you diff traces between environments.

## The claims I keep hearing, checked

**"The failing stage sees the raw exception; downstream stages see `CompletionException`."** Half true. Lab `stage` ran `supplyAsync(...).exceptionally(ex -> ...)` and the handler on the *same* stage as the supplier printed `java.util.concurrent.CompletionException`, not `IllegalArgumentException`. A future completed by hand (a separate throwaway probe, not in `Lab.java`) with `completeExceptionally(new IllegalArgumentException(...))` does hand `exceptionally` the raw exception, and a stage after it gets the wrapper. So it depends on whether the exception came out of a supplier (wrapped by `AsyncSupply`) or was injected directly. Check `ex.getCause()` if you need the real type, or unwrap with a loop.

**"`join()` throws the same exception as `get()`."** No. `get()` throws checked `ExecutionException`, `join()` throws unchecked `CompletionException`. In both, the interesting trace is under `Caused by`.

**"Rethrowing in `exceptionally` gives me a clean trace."** My `chain` lab rethrew `new IllegalStateException("retry exhausted", ex)` from `exceptionally` and got a three-deep chain: `CompletionException` → `IllegalStateException` → `CompletionException` → `IllegalArgumentException`. The root cause is at the bottom, three `Caused by` blocks down, and the outer `CompletionException` carries frames from `exceptionally(Lab.java:49)`, the *wiring* site, not the failure site. Here the callback ran on `main` at wiring time; with different timing it can run on the worker, so don't build anything on which one you get. If your alerting groups on the top frame, every error in that chain will look identical.

**"`submit()` and `execute()` are interchangeable."** Not for failures. `execute()` hands the exception to the thread's uncaught handler (default: stderr). `submit()` hands it to the `Future`. If nobody reads the `Future`, nobody ever sees it. The `handler` lab confirmed this; the handler fired for the `execute()` task only, and the `submit()` future only reported `isDone=true`.

## Getting the submit site back into the trace

You can't recover a submitter's stack after the fact, but you can capture it at the moment of submission, which costs one `new Exception()`. My `tagged()` wrapper constructs an exception on the caller's thread and attaches it as a *suppressed* exception when the task fails:

```log
java.lang.IllegalArgumentException: empty body for evt_7
	at Lab.sign(Lab.java:8)
	at Lab.handle(Lab.java:13)
	at Lab.lambda$tag$12(Lab.java:76)
	at Lab.lambda$tagged$11(Lab.java:69)
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)
	Suppressed: java.lang.Exception: submitted from main
		at Lab.tagged(Lab.java:67)
		at Lab.tag(Lab.java:76)
		at Lab.main(Lab.java:104)

```

Now the trace says who submitted: `Lab.tag` line 76, called from `main` line 104\. I chose `addSuppressed` over `initCause` because the original exception may already have a cause, and I didn't want to overwrite or reorder the chain ([how Suppressed prints](https://ggorantala.dev/java-suppressed-exceptions/?ref=ggorantala.dev) covers why it lands after the frames). Costs, all real: one extra `Throwable` allocation and a stack walk on every submit, which is not free on a hot path (measure before you wrap all of it); a larger log line on every failure; and you must wrap *every* submission point, because the one you forget is the one that fails at 3 a.m. If submit rate is very high, capture the exception only when a flag or sampling says so.

Other things that help, none of them clever: name your threads (`ThreadFactory` or `Thread.ofPlatform().name(...)`) so the first line of `Exception in thread "..."` tells you which pool; put the event id in the exception message, as I did with `evt_N` here, because the stack won't tell you which input broke; and use a [custom exception with real context](https://ggorantala.dev/java-custom-exceptions-stack-traces/?ref=ggorantala.dev) rather than relying on the trace to carry it.

For the `CompletableFuture` error-handling API itself (`handle`, `whenComplete`, timeouts), I'm leaving that to the dedicated CompletableFuture exceptions post; this one is only about reading what comes out.

## What your logs see, and on which JDK

Three production habits that follow from the labs.

Install a default handler. `Thread.setDefaultUncaughtExceptionHandler` or a per-factory handler (my `handler` lab) gets `execute()` failures into your logger rather than raw stderr. It will never see `submit()` failures; for those, check the `Future`, or use a wrapper that logs and rethrows. Log once, at the boundary, so you don't get the same chain three times.

Log the *whole* throwable, never `getMessage()`. For `CompletionException` and `ExecutionException` the message is the cause's `toString()`, so message-only logging at least keeps the type name, but it loses every frame. I'll cover SLF4J specifics in the [logging post](https://ggorantala.dev/logging-java-exceptions-correctly/?ref=ggorantala.dev).

When you group errors by top frame, unwrap first. `CompletionException`'s top frames are `encodeThrowable` and `completeThrowable` on every failure in every pipeline, so a fingerprint taken from them will lump unrelated bugs together. Unwrap to the root cause and fingerprint that.

Version safety: everything above was run on OpenJDK 21.0.12.1 only. I did not run JDK 8, 17 or 25, so line numbers inside `CompletableFuture.java`, `FutureTask.java` and `ThreadPoolExecutor.java` are 21's, and the exact helper frames (`encodeThrowable`, `AsyncSupply.run`) are the kind of thing that moves between releases. The structure (separate stacks for caller and worker, wrapper created on the worker, `submit()` swallowing) is old and I'd expect it on 8 and 17, but I haven't seen it there. Logback and Log4j2 were not part of this lab. Getting to the bottom of this took me most of two evenings, a good part of it chasing an "origin stack" the JDK doesn't keep.

## The short list

- A stack belongs to one thread. The worker's trace will never contain the submitter.
- `get()` and `join()` give you two stacks in one trace: the waiter's on top, the failing worker under `Caused by`.
- A `CompletionException` can print `Exception in thread "main"` over frames from a worker, because frames are captured at construction.
- `submit()` with no `get()` loses the exception. `execute()` prints it from the worker.
- To get the submit site back, capture a `new Exception()` at submission and attach it as `Suppressed`. It costs an allocation per submit.

## Questions that come up

**Why does my CompletableFuture stack trace show Thread.run or ForkJoinWorkerThread.run and not my method?** Because the exception was built on the worker thread. The caller's frames aren't in it. On JDK 21 the bottom frame depends on common-pool parallelism: above 1 you get `ForkJoinWorkerThread.run`, at 1 (a one- or two-core container) `Thread.run`.

**What's the difference between CompletionException and ExecutionException?** `ExecutionException` is checked and comes from `Future.get()` / `CompletableFuture.get()`. `CompletionException` is unchecked and comes from `join()` and from dependent stages. In both, your real exception is `getCause()`.

**How do I find the caller of a task that failed in a thread pool?** The runtime doesn't keep it. Capture a `new Exception("submitted from ...")` when you submit and attach it with `addSuppressed` when the task fails, or put a correlation id in the message and the MDC.

**Why did my `submit()` task fail silently?** The exception is held in the `Future` until someone calls `get()`. If nobody does, nothing prints. Use `execute()` with an uncaught handler, or always check the `Future`.