Why Your Coroutine Exceptions Lose Their Stack Trace — And How Kotlin 2.4.20 Fixes It
You’ve seen this one. An exception gets thrown three suspend calls deep, gets caught and rethrown by a supervisor, and by the time it hits your logging sink the stack trace points at kotlinx.coroutines.internal.DispatchedCoroutine and nothing else useful. You know something failed. You have no idea where. So you grep the codebase for the exception message and hope it’s unique enough to find the actual call site.
TL;DR
kotlinx.coroutinesalready recovers stack traces automatically — but only for exceptions with simple constructors (message, cause, both, or nothing)- Custom exceptions with extra required constructor arguments (an error code, a line number, a request ID) silently break this recovery — the library can’t build a copy of your exception
- Kotlin 2.4.20-Beta1 (released June 24, 2026) adds a new
StackTraceRecoverableinterface to the standard library to close this gap - You implement one method,
copyForStackTraceRecovery(), and the coroutine machinery uses it to rebuild your exception with full trace context - The API is Experimental, requires
@OptIn(ExperimentalStdlibCoroutineSupportApi::class), and onlykotlinx.coroutineson the JVM actually uses it today - This doesn’t fix coroutine debugging in general — it fixes one specific, common failure mode: custom exception types with non-trivial constructors
How Kotlin Coroutine Stack Trace Recovery Works — And Where It Silently Fails
kotlinx.coroutines has quietly done something clever for years. When an exception crosses a suspend boundary and gets rethrown by a different coroutine, the library doesn’t just propagate the same exception object. It creates a new instance of the exception and stitches in the original coroutine’s stack frames, so the final trace shows both where the exception was created and where it resurfaced.
This happens automatically, but only under one condition: the exception class needs a constructor the library can call reflectively with a subset of (message), (cause), (message, cause), or no arguments at all. Standard exceptions, most RuntimeException subclasses, anything you’d write in a five-minute PR — all of it just works.
The Common Case That Breaks: Exceptions With Real Constructor Arguments
The moment your exception carries actual data — a lineNumber, an errorCode, a requestId you need downstream — the automatic recovery has nothing to call. There’s no reflective path to a constructor that takes (lineNumber: Int, detail: String). Before 2.4.20, this failed silently. No crash, no warning. The exception just propagates without the recovered trace, and you get exactly the confusing, machinery-only stack trace described above — except now you also lose your custom fields in the copy, because there was never a copy.
// This exception has always been invisible to stack trace recovery
class FileEditException(
val line: Int,
detail: String,
) : IllegalStateException("When editing line $line: $detail")
// Thrown deep in a suspend chain, rethrown by a supervisor —
// the trace shows coroutine internals, not your call site
If you’ve ever wondered why some exceptions in your logs have beautiful, traceable stack traces and others in the same service look like garbage, this is almost certainly why. It’s not random. It correlates directly with whether the exception class has a “simple” constructor.
Kotlin Pitfalls: Beyond the Syntactic Sugar Moving to Kotlin isn't just about swapping semicolons for conciseness. While the marketing says "100% interoperable" and "null-safe," the reality in a Kotlin codebase complexity environment is different....
Why Doesn’t Kotlin Fix This Automatically For Every Exception Type?
Because the library can’t safely guess how to reconstruct an object it doesn’t understand. If your exception constructor requires a lineNumber, the library has no way to know what value to pass without you telling it. Reflection-based guessing would either fail loudly on every custom exception or silently produce wrong data — neither is acceptable for something that runs on every thrown exception in production.
Kotlin 2.4.20-Beta1: The StackTraceRecoverable Interface Explained
This is the actual fix, and it’s small. You implement one interface, override one method, and tell the library exactly how to rebuild your exception.
import kotlin.coroutines.ExperimentalStdlibCoroutineSupportApi
import kotlin.coroutines.StackTraceRecoverable
@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
class FileEditException
private constructor(
val line: Int,
private val detail: String,
cause: Throwable?,
) : IllegalStateException("When editing line $line: $detail", cause),
StackTraceRecoverable<FileEditException> {
constructor(line: Int, detail: String) : this(line, detail, null)
override fun copyForStackTraceRecovery(): FileEditException =
FileEditException(line, detail, this)
}
copyForStackTraceRecovery() returns a fresh instance carrying your original data, with this passed as the cause — that’s how the original throw site stays reachable in the final chain. Return null from the override if you’d rather opt a specific exception out of recovery entirely; the library will leave it alone.
@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
fun main() {
val original = FileEditException(15, "Unexpected token")
val copy = original.copyForStackTraceRecovery()
println(copy.message) // When editing line 15: Unexpected token
println(copy.cause == original) // true
}
Notice the private constructor pattern. It’s not decoration — IllegalStateException‘s message needs line and detail computed before super() runs, so the public constructor delegates to a private one that also accepts the cause. If you’re retrofitting an existing exception hierarchy, this constructor-splitting is the part that actually takes time, not the interface implementation itself.
What Is StackTraceRecoverable in Kotlin?
StackTraceRecoverable is a standard library interface introduced in Kotlin 2.4.20-Beta1 that lets an exception class define its own logic for creating a copy of itself during coroutine stack trace recovery. It exists specifically for exception types whose constructors take more than a message and a cause, which the automatic recovery path in kotlinx.coroutines can’t handle on its own.
Kotlin Gradle Plugin Failures: KAPT vs KSP Traps, Multiplatform SourceSet Issues and Build Cache Problems A practical troubleshooting guide for Android, backend, and Kotlin Multiplatform projects — covering annotation processing, serialization, coroutines, linting, and incremental...
Is StackTraceRecoverable Stable in Kotlin 2.4.20?
No. It ships as an Experimental API and requires @OptIn(ExperimentalStdlibCoroutineSupportApi::class) to use. Treat it the way you’d treat any Experimental stdlib API — fine to adopt in internal tooling and non-critical paths now, but expect the annotation requirement, and possibly the API shape, to still shift before it stabilizes.
Does StackTraceRecoverable Work on Kotlin/Native or Kotlin/JS?
The interface itself is available on all targets — JVM, Native, JS, Wasm. But right now kotlinx.coroutines only actually performs stack trace recovery using it on the JVM. If you implement StackTraceRecoverable in a Kotlin Multiplatform module, the JVM target gets the benefit today; other targets get the interface with no behavioral effect yet.
Do I Need kotlinx.coroutines as a Dependency to Use This?
No, and that’s the point of putting it in the standard library rather than in kotlinx.coroutines itself. You implement StackTraceRecoverable against kotlin.coroutines.StackTraceRecoverable with zero dependency on the coroutines library. If your project happens to use kotlinx.coroutines, it picks up your implementation automatically at runtime. If it doesn’t, the interface is simply unused — no dead dependency, no version coupling.
Which Exceptions Are Worth Retrofitting First
Selectively, yes — but not as a sweep across your whole codebase. The exceptions worth fixing first are the ones you already know produce useless stack traces in production: domain exceptions with an error code, parsing exceptions with a line/column, anything you’ve had to grep for by message text because the trace didn’t help. Those are your highest-value targets.
Exceptions that already work fine with simple constructors don’t need this — you’d be adding an interface and an @OptIn annotation for zero behavioral change. And since this is Experimental, wrap the retrofit in whatever pattern you use for provisional APIs, so removing the @OptIn later, if the API shape changes, is a one-place fix rather than a codebase-wide hunt.
If you’re already running DebugProbes to catch leaked coroutines in production, this pairs naturally with that workflow — recoverable stack traces make the dumps DebugProbes.dumpCoroutines() produces meaningfully more useful for exception-carrying jobs, not just for the ones that just hang.
FAQ: Kotlin Coroutine Stack Trace Recovery
What Kotlin version introduced StackTraceRecoverable?
Kotlin 2.4.20-Beta1, released June 24, 2026. It’s part of the standard library, not the language itself, so it will ship as part of the eventual stable 2.4.20 tooling release.
Why does my custom exception lose its stack trace in coroutines?
Because kotlinx.coroutines‘s automatic stack trace recovery can only reflectively construct exceptions with simple constructors — message only, cause only, both, or neither. If your exception requires additional arguments like an error code or line number, recovery silently fails and the trace shows only coroutine internals.
Solving Kotlin Type Inference Problems for Junior and Middle Developers Kotlin is praised for its concise syntax and safety, but it can trip up developers in subtle ways. One major challenge is Kotlin type inference...
What does copyForStackTraceRecovery() need to return?
A new instance of your exception carrying the same custom data as the original, with the original passed as the cause so it remains reachable in the exception chain. Return null if you want to explicitly opt that exception out of recovery.
Does implementing StackTraceRecoverable have a performance cost?
The cost is the same as what already happens for simple exceptions during recovery — one additional object allocation per recovered exception. There’s no reflection involved in your path since you’re providing the constructor logic directly, which is actually cheaper than the reflective lookup used for simple exception types.
Can I use StackTraceRecoverable without kotlinx.coroutines?
Yes, the interface lives in the Kotlin standard library with no dependency on kotlinx.coroutines. It only has a practical effect once code using kotlinx.coroutines on the JVM encounters your exception during coroutine execution.
Is StackTraceRecoverable related to third-party libraries like stacktrace-decoroutinator?
They solve the same underlying problem from different angles. Third-party bytecode-instrumentation libraries rebuild the full coroutine call stack by generating synthetic JVM frames at runtime — powerful, but heavier and JVM-agent-based. StackTraceRecoverable is a lighter, opt-in stdlib mechanism that only fixes recovery for the specific exception types you implement it on. They’re not mutually exclusive, but most teams won’t need both.
Will StackTraceRecoverable become stable soon?
There’s no announced timeline. JetBrains ships Experimental stdlib coroutine-support APIs behind @OptIn specifically to gather feedback before committing to the shape. Track the feature’s linked YouTrack issue if you want to catch changes before the stable release.
What should I do if I don’t want a specific exception recovered at all?
Implement StackTraceRecoverable, and return null from copyForStackTraceRecovery(). The library treats a null return as an explicit opt-out and leaves that exception instance untouched during recovery.
Written by: