Kotlin 2.4.20 RC: A Maintenance Patch That Finally Fixes The Small Things

I was debugging a suspended coroutine at 11pm last week, staring at a stack trace that told me exactly nothing about where the call originated. Just a wall of dispatcher internals and a single line pointing at my code, three frames removed from the actual bug. Anyone who has shipped production Kotlin with coroutines knows this ritual: you add breadcrumbs, you sprinkle logging, you eventually give up and bisect the code by commenting things out. So when I opened the Kotlin 2.4.20 RC changelog and saw a line about stack trace recovery baked directly into the standard library, I didn’t skim past it. That’s the kind of change that doesn’t trend on Twitter but saves you real hours.

There’s no new language feature in this release. No syntax you’ll screenshot for a “look what Kotlin can do now” post. What it is, instead, is a maintenance-and-polish release that touches five different corners of the ecosystem — stdlib, Kotlin/Native’s Swift export, Kotlin/Wasm, Kotlin/JS, and the build tooling layer — and in every one of those corners, the theme is the same: reduce the gap between “it compiles” and “it’s actually pleasant to use in a real project.” That’s a less exciting pitch, but it’s the kind of release that quietly makes your Tuesday better six months from now. Below is what’s actually worth your attention, what to skip for now, and where I think JetBrains still left something on the table.

Coroutines finally get honest about where things break

The most consequential change in this release, at least for backend and Android folks, is StackTraceRecoverable, and it’s the one thing here I’d genuinely call a must-know. Kotlin’s coroutine machinery has always had a structural problem: because a suspended function can resume on a completely different thread than the one that suspended it, the JVM’s native stack trace tells you almost nothing useful. kotlinx.coroutines solved this years ago with its own stack trace recovery mechanism, but that mechanism lived inside the coroutines library itself, which meant if you were writing a library that used suspend functions but didn’t want a hard dependency on kotlinx.coroutines, you were stuck with broken traces.

StackTraceRecoverable moves the interface into stdlib. Now any exception type can opt into telling the runtime how to reconstruct a meaningful trace across suspension points, without your library needing to import coroutines just for that. No new dependency required — that’s the actual win here. If you maintain a multiplatform library, or you’ve ever had to explain to a junior dev why the exception they’re staring at says “at kotlinx.coroutines.internal.DispatchedContinuation” instead of their own function name, this is the fix.

The catch: it’s marked experimental and gated behind @OptIn(ExperimentalStdlibCoroutineSupportApi::class). That’s the right call from JetBrains — this is exactly the kind of API where getting the contract wrong now means years of migration pain later — but it also means you shouldn’t wire it into a public library’s stable surface yet. Use it in application code where you control both sides, watch how it behaves across a few coroutine-heavy releases, and hold off on baking it into a published SDK until it graduates.

What’s still missing here, and I’d genuinely like to see in a follow-up: there’s no equivalent story yet for structured concurrency debugging in general — you still can’t easily answer “what’s the full coroutine tree right now” from inside a running JVM process without reaching for a debugger extension or the coroutines debug agent. Stack trace recovery fixes the “where did this exception come from” question. It doesn’t fix the “what’s actually still running” question, which is the harder one in production incident response.

Four small collection functions that quietly remove a chunk of boilerplate

Every Kotlin codebase I’ve touched has a version of this pattern somewhere: checking whether a list of orders all belong to the same customer, whether a batch of records all share a status, whether a set of IDs contains no duplicates before you commit them. Usually it’s written as .distinct().size == list.size or a manual fold, and it works, but it doesn’t read as intent — you have to mentally simulate what the code is checking. Kotlin 2.4.20 gives you four new functions that fix exactly that:

Deep Dive
Kotlin Dependency Injection

Koin, Dagger, Hilt: Kotlin Dependency Injection Performance Your Kotlin dependency injection choice is the difference between a 2-minute build and a coffee break you didn't ask for. For Junior and Middle devs scaling to 80+...

Function What it checks
.allDistinct() Every element in the collection is unique
.allDistinctBy { } Every value of the selected property is unique
.allEqual() Every element in the collection is the same
.allEqualBy { } Every value of the selected property is the same

These land on Iterable, Sequence, and Array, which matters more than it sounds like — sequence support means you get the same semantics on a lazily evaluated pipeline without materializing an intermediate list just to run a check. A validation step in a streaming pipeline can now stay lazy end to end.

Is this a big deal on its own? No. But it’s the kind of function that, once it exists, you start noticing everywhere in code review — a .distinctBy { it.status }.size == 1 check that a teammate wrote last year, that you can now flag as “hey, we have .allEqualBy { it.status } for this now, it’s clearer intent.” Small stdlib additions like this compound over a codebase’s lifetime more than people give them credit for, because they change what idiomatic code looks like in PR review, not just what’s technically possible. Like the coroutine change, these are experimental too, behind @OptIn(ExperimentalStdlibApi::class) — fine for internal application code, hold off wiring them into a published library’s public API surface.

Swift export stops being a demo and starts being a real interop story

If you work on a Kotlin Multiplatform team shipping to iOS, you know the historical pain point: the Objective-C bridge that KMP has relied on for years is functional but ugly. Sealed classes turn into a mess of subclasses with no exhaustiveness checking on the Swift side, and anything resembling idiomatic Swift required hand-written wrapper code. This release moves the newer, direct Swift export path — clearly where JetBrains is investing — two real steps forward.

First, sealed class hierarchies from Kotlin now map to genuine Swift enums, which means Xcode gives you actual exhaustive switch checking and autocomplete on the Swift side, and a compiler warning the moment someone adds a new Kotlin subclass and the Swift code hasn’t been updated to handle it. If you’ve ever shipped a bug because a new sealed subclass silently fell through a Swift if-else chain with no default case, you’ll understand why this matters more than it sounds.

Second, and more interesting architecturally: you can now declare an interface in Kotlin, implement it in Swift, and pass that Swift object back into a Kotlin function expecting the interface. That’s genuine bidirectional interop, not just “call Kotlin from Swift.” It opens the door to using pure Swift libraries from shared Kotlin code without writing an expect/actual wrapper for every single call, which has been one of the more tedious parts of maintaining a serious KMP codebase.

Third, if your shared module depends on a Swift Package Manager package and you’re producing an XCFramework, the assembleSharedXCFramework task now auto-generates the Package.swift manifest for you. Minor on paper, but it removes a manual sync step that was a recurring source of “why does CI build a different artifact than my local machine” bugs.

None of this makes the Obj-C bridge path obsolete yet — plenty of production KMP apps still depend on it, and migrating isn’t free. There’s still no official migration guide from the Obj-C bridge for teams with an existing production codebase, just “it’s better for new projects,” and that’s a real gap. But if you’re starting a new multiplatform project today, this is a strong signal that direct Swift export is the path JetBrains wants you on within a release or two, and it’s worth prototyping now rather than waiting for a forced migration later.

Technical Reference
Kotlin Production Bugs

Why Kotlins Safety Features Still Blow Up in Production Kotlin is often marketed as a language that removes runtime crashes and eliminates kotlin null pointer exception issues, but in real production systems those guarantees quickly...

Wasm gets one breaking fix and one quiet correctness fix

Two changes here worth flagging separately, because they hit different audiences.

The breaking one: require() no longer works inside @JsFun. If you were using that pattern to pull in CommonJS-style dependencies inside inline JS functions, it’ll fail to compile going forward. The migration path is @JsModule or dynamic import(), both of which are the more correct approach anyway — require() inside a Wasm/JS boundary function was always a bit of a hack that happened to work. If your Kotlin/Wasm project touches JS interop at all, grep for @JsFun combined with require( before you upgrade, because this one will fail your build, not just warn.

The quiet one: companion object initialization order for Kotlin/Wasm now matches the JVM — superclasses initialize before subclasses. This is the kind of bug that’s genuinely hard to catch in code review because it only manifests when a subclass’s companion object reads state from a superclass’s companion object during static initialization, which is rare but not rare enough. If you’ve ever ported a class hierarchy from JVM Kotlin to Wasm and hit mysterious null or default values during startup, this was very possibly why.

There’s also Wasmtime support for the wasmWasi target now, as an alternative to Node.js, added with a single wasmtime() line in Gradle. Useful mainly for teams running Wasm outside a browser or Node context — server-side Wasm runtimes, edge functions, that kind of deployment. Not something most app developers need today, but a sign the Wasm target is being taken seriously as a standalone deployment story, not just a browser trick.

Kotlin/JS testing catches up to the current decade

Karma has been Kotlin/JS’s default browser test runner for years, and anyone who’s maintained a Kotlin/JS CI pipeline knows it’s showing its age — flaky launcher configuration, awkward headless Chrome setup, and a maintenance pace that’s slowed considerably in the broader JS ecosystem. The new DSL swaps in Mocha plus Webpack (with Vite support coming) plus Playwright, giving you real multi-browser testing across Chromium, Firefox, and WebKit with a config that reads like normal Kotlin instead of a pile of Karma launcher JSON:

js {
    browser {
        @OptIn(ExperimentalJsTestDsl::class)
        test {
            chromium { }
            firefox()
            webkit { }
        }
    }
}

This alone is worth upgrading for if your team has been putting off cross-browser testing because the Karma setup was too brittle to trust in CI. Playwright’s reliability in headless CI environments is a meaningfully different experience than what most teams have tolerated from Karma.

Separately, suspending lambdas can now be exported as native JavaScript async functions, behind the -Xsuspend-lambda-exporting flag. If you’re exposing a Kotlin/JS API surface to a JS or TypeScript consumer, this removes an entire category of manual Promise-wrapping code you’d otherwise have to hand-write around every suspend callback you expose.

Build Tools API widens its net, and a native compiler shows up

The Build Tools API — the abstraction layer that lets build systems talk to the Kotlin compiler without depending on internal compiler classes directly — now covers Kotlin/JS, Kotlin/Wasm, and Kotlin metadata targets, not just JVM. It’s opt-in via gradle.properties for now, with JetBrains signaling it becomes default in 2.5.0. If you maintain custom Gradle plugins or build tooling that shells out to the compiler directly for non-JVM targets, this is worth testing early, because the 2.5.0 default flip will surface any assumptions your tooling made about the old internal APIs.

And there’s a first experimental build of a native-image version of the Kotlin compiler — a drop-in replacement for kotlinc compiled ahead-of-time instead of running on a JVM, promising faster cold start. This matters most for CLI tooling, scripts, and CI steps that spin up the compiler repeatedly for small tasks, where JVM warm-up time is a real, measurable cost. It’s very early — experimental, first release — so I wouldn’t swap it into a CI pipeline you depend on yet, but it’s worth a personal benchmark if compiler startup latency has ever shown up in your build time profiling.

Worth Reading
Kotlin 2.4

Mastering Contextual Abstraction with Kotlin 2.4 Stable Parameters I've been waiting for the death of -Xcontext-parameters since the first previews. Not because the feature was bad — it was always promising — but because "experimental"...

What I’d still like to see

Taken as a whole, this release is JetBrains cleaning up debt across the multiplatform surface rather than adding new capability, and I think that’s the right call at this point in Kotlin’s maturity curve. But a few gaps stood out to me while going through it. The coroutine debugging story is still fragmented across stack trace recovery (this release), the coroutines debug agent, and IDE-level coroutine inspection — three separate tools solving overlapping parts of “help me understand what’s running.” A unified story here would save a lot of onboarding pain for teams new to structured concurrency. And on the stdlib collection functions: allDistinct() and friends are welcome, but there’s still no equivalent for the extremely common “group and validate uniqueness within groups” pattern that shows up in any data-validation-heavy codebase — you still end up hand-rolling that with groupBy and a follow-up check.

How to actually try this

If you want to kick the tires before the stable release, bump your Kotlin version to the 2.4.20 RC coordinate in your Gradle catalog, and be deliberate about it: don’t do this on a shared team branch first. Try it on a personal branch, run your existing test suite, and specifically watch for two things — any @JsFun blocks using require() if you touch Kotlin/Wasm, and any companion object that reads superclass companion state during init if you’re on Kotlin/Wasm at all. Those are the two changes in this release that can actually break a build rather than just add a nice-to-have.

FAQ

Is Kotlin 2.4.20 a stable release?
No — this is a release candidate. Stable typically follows a few weeks after RC, once JetBrains has gathered feedback on the experimental APIs and confirmed no regressions in the Wasm and JS toolchains.

Will upgrading to 2.4.20 RC break my existing Kotlin/JVM project?
For most JVM-only projects, no — the breaking changes in this release are concentrated in Kotlin/Wasm’s JS interop (require() in @JsFun) and companion object init order on Wasm. Pure JVM and Android projects should upgrade with low risk, though as with any RC, run your full test suite first.

How do I roll back if I hit a regression?
Roll back is straightforward: pin the Kotlin version in your version catalog (or build.gradle.kts) back to your previous stable release, and if you’d already flipped the Build Tools API opt-in flag in gradle.properties, remove that line too. Since nothing in this release requires a data or format migration, reverting the version number is enough to get back to your previous behavior.

Should I use StackTraceRecoverable in a library I publish?
Not yet. It’s marked experimental behind an opt-in annotation specifically because the API contract may still change. Use it in internal application code now, and wait for it to stabilize before committing to it in a public library API.

Do I need kotlinx.coroutines to use the new stack trace recovery?
No — that’s the point of the change. The interface now lives in stdlib, so any code with suspend functions can implement stack trace recovery without adding a dependency on kotlinx.coroutines.

Is Swift export ready to replace the Objective-C bridge for production KMP apps?
For new projects, it’s mature enough to seriously evaluate. For existing production apps built on the Obj-C bridge, there’s no official migration tooling yet, so treat this release as a strong signal of direction rather than a mandate to migrate immediately.

Written by:

Source Category: Kotlin: Hidden Pitfalls