A recent article, The Hidden Cost Behind Kotlin Multiplatform on iOS, looked at what KMP does to the iOS edit/build/run feedback loop. It’s a good question to ask and the article also comes with a benchmark harness (kmp-ios-benchmark) that compares a Swift-only setup against an equivalent KMP one, using generated code across clean, incremental, public API change, multi-module and release builds. The headline numbers were that a single internal change rebuilt in 3.19s for Swift vs 10.36s for KMP, and that the gap grew with module size (KMP adding ~4.2x more time per 100 files than Swift).

There were a few things the article didn’t cover that I wanted to look at: whether newer Kotlin versions make any difference, whether a single “umbrella” framework helps in the multi-module case, and how much of the cost comes from the Kotlin API that’s exposed to Swift. I also compared the default Objective-C based framework with Kotlin’s (experimental) Swift export. This article goes through what I ran and what I found.

TL;DR: the article’s measurements reproduce well, and newer Kotlin versions (2.4.20, 2.5.0-Beta1) don’t change them much. However the thing that made the biggest difference was one the article doesn’t mention….how much of your Kotlin API is exposed to Swift. Making the generated code internal (and combining that with kotlin.incremental.native=true) took a 400 file module’s incremental rebuild from 13.3s down to 3.8s, close to Swift’s 3.3s. This has an even bigger effect with Swift export: with everything public a one-line change in a 400 file module took ~50s (vs ~17s for the Obj-C framework), but with internal code it came down to 11s.

Setup

All runs were on an Apple M5 Max with Xcode 27.0 and Gradle 9.6.1, using the harness defaults of 200 generated files per module and the median of 3 trials (after a warmup run) per scenario. A few changes were needed to get the harness running:

  • The four iOS app shells the Xcode project generators reference (AppSwift, AppKMM etc) aren’t committed to the repo, so I added minimal (and identical for the Swift and KMP sides) versions of those.
  • The Gradle wrapper was 8.5, which doesn’t run on JDK 25, so it was updated to 9.6.1 (and kept the same across all the Kotlin versions so only Kotlin changed between runs).
  • I added two additional multi-module scenarios, the ability to generate the code with internal visibility, and Xcode-integrated scenarios for comparing the Obj-C framework with Swift export (all covered below).

Absolute numbers are faster than in the original article (different machine) but the KMP/Swift ratios are very close.

Kotlin versions

The original numbers were captured with Kotlin 2.3.0. I re-ran everything with 2.3.0 on this machine as a baseline and then with 2.4.20 and 2.5.0-Beta1.

Scenario 2.3.0 2.4.20 2.5.0-Beta1
Swift clean 5.42s 5.51s 5.68s
KMP clean 9.84s 9.84s 10.24s
KMP clean (cold Gradle daemon) 16.17s 15.69s 16.22s
Swift incremental 2.08s 2.14s 2.17s
KMP incremental 6.70s 6.92s 7.15s
KMP incremental (kotlin.incremental.native) 4.85s 4.87s 5.13s
KMP public API change 6.44s 6.76s 6.99s
Swift release 11.64s 11.93s 12.06s
KMP release 29.65s 28.56s 29.50s

The Kotlin side is within a few percent across all three versions, and the Swift-only numbers (identical code and toolchain in each run) moved by about the same amount, so there’s no meaningful difference between them. KMP incremental builds are ~3.2x Swift, in line with the article. The experimental kotlin.incremental.native flag gives a consistent ~28-30% improvement, again matching the article.

Multi-module: separate frameworks vs umbrella

The article’s 3-module cascade scenario (ModuleA → ModuleB → ModuleC, changing the public API of the leaf module) builds a separate framework for each module and links the app against all three. Each framework also includes the code for the modules it depends on (so ModuleAKit contains all three modules), along with its own copy of the Kotlin runtime. The more common approach is a single “umbrella” framework, and the harness actually already included one in ModuleA’s build file (just not used in any scenario).

1
2
3
4
5
6
iosTarget.binaries.framework("MultiModuleKit") {
    baseName = "MultiModuleKit"
    isStatic = true
    export(project(":kmp:moduleB"))
    export(project(":kmp:moduleC"))
}

With only one link step, and no duplicated code or Kotlin runtime, you’d expect that to be faster. It actually turned out to be the slowest of the three.

Scenario (Kotlin 2.4.20) Time vs Swift
Swift 3-module cascade 5.71s  
KMP, 3 separate frameworks 16.34s 2.9x
KMP, umbrella exporting all 3 modules 17.76s 3.1x
KMP, umbrella exporting only ModuleA 8.38s +47%

With org.gradle.parallel=true the three separate framework link tasks run concurrently, so you pay for roughly the slowest one rather than all three. The umbrella is a single, larger link that has to generate the Objective-C header (and associated bridging) for the public API of all three modules (600 generated files), and that’s what dominates. Removing the two export() lines (so ModuleB and ModuleC are still compiled into the framework but their API isn’t exposed to Swift) halves the build time. The Gradle logs were checked to confirm that all three compileKotlinIosSimulatorArm64 tasks and the link task still ran in that case.

So the number of frameworks didn’t make much difference here….it was how much of the API was being exported.

internal vs public

I also wanted to see how much of the single-module cost was down to the exported API. Every class and function in the harness’s generated Kotlin code is public, so adding more files also increases the size of the exported API, and it’s not possible to tell from the article’s results which of those is driving the cost.

To test that I added a switch to the code generator that makes all the generated declarations internal (for both the Kotlin and Swift code). One gotcha here was that Kotlin/Native removes any internal code that isn’t referenced when linking the framework, so the internal version would have ended up with a lot less code to build and link. To avoid that, each module also gets a single public generatedEntryPoint() function that calls into every generated file (and is included in both the public and internal variants). The framework header and binary were also checked to confirm that, in the internal case, the header only contains that one function while the binary still contains the code from all the generated files.

These are the results for the internal change scenario with Kotlin 2.4.20 (the public API change scenario gave almost identical numbers).

Files Swift public Swift internal KMP public KMP internal KMP + incremental public KMP + incremental internal
100 1.62s 1.60s 5.31s 3.34s 4.06s 2.44s
200 2.19s 2.08s 7.36s 4.87s 5.10s 2.89s
400 3.55s 3.31s 13.31s 8.04s 8.43s 3.81s

And the added time per 100 files (linear fit across the three module sizes):

  public internal
Swift 0.65s 0.58s
KMP 2.71s 1.57s
KMP + kotlin.incremental.native 1.49s 0.46s

A few things stood out:

  • With everything public I reproduce the article’s ~4.2x per-100-files ratio. Going internal cuts KMP’s incremental time by 34-40%, while Swift only changes by a few percent.
  • The two changes also work well together. At 400 files the incremental flag saves 37% when everything is exported but 53% when it’s internal. With both, KMP’s incremental cost grows more slowly than Swift’s as the module gets bigger, and at 400 files is within ~1.15x of Swift.
  • There’s still a fixed overhead per KMP build (Gradle invocation, copying and relinking the framework), so the ratio is worse for small modules (~1.5x at 100 files, even with both changes).

I re-ran the same tests with Kotlin 2.5.0-Beta1 and the results were very similar, with everything within a few percent (3.94s for KMP internal + incremental at 400 files, vs 3.34s for Swift).

Swift export

Everything above uses the default Objective-C based framework, where Swift sees your Kotlin code through a generated Objective-C header. Kotlin also has an experimental Swift export mode which generates Swift bindings directly. In 2.5.0-Beta1 it’s configured using the new export { swift { } } DSL (the older swiftExport { } block is now deprecated).

1
2
3
4
5
6
7
8
9
10
kotlin {
    @OptIn(ExperimentalSwiftExportDsl::class)
    export {
        swift {
            moduleName.set("Shared")
            rootPackage.set("com.example.shared")
            xcodeIntegration()
        }
    }
}

Unlike the link...Framework tasks the harness calls directly, Swift export is driven from an Xcode “Run Script” build phase that calls ./gradlew :kmp:shared:embedSwiftExportForXcode (it reads Xcode’s environment variables), and it needs an iOS deployment target of at least 18.0. So that both were being built the same way I added two Xcode projects that both run Gradle from a build phase….one using embedAndSignAppleFrameworkForXcode (the standard integration for the Obj-C framework) and one using embedSwiftExportForXcode, both targeting iOS 18. All numbers in this section are with Kotlin 2.5.0-Beta1.

These are the results for the internal change scenario (again, the public API change scenario was very similar).

Files Swift Obj-C public Swift export public Obj-C internal Swift export internal
100 1.66s 5.08s 11.53s 3.62s 5.56s
200 2.21s 7.70s 21.07s 4.94s 7.49s
400 3.57s 16.53s 49.67s 8.34s 11.13s

And with kotlin.incremental.native=true:

Files Obj-C public Swift export public Obj-C internal Swift export internal
100 4.00s 10.54s 2.54s 4.71s
200 5.27s 18.76s 2.92s 5.51s
400 9.10s 43.29s 3.89s 7.17s

A clean build (200 files, public) took 5.63s for Swift, 10.12s with the Obj-C framework and 37.58s with Swift export.

  • With everything public, Swift export is currently 2.3-3x slower than the Obj-C framework for incremental builds (and ~3.7x for a clean build). Looking at the Gradle tasks, every change regenerates the Swift bindings (~113k lines of Swift for the 400 file public module), builds those as a Swift package and then links, all of which scales with the exported API.
  • So the size of the exported API has an even bigger effect here. Going internal cut Swift export’s incremental time by 52-78% (49.7s down to 11.1s at 400 files), and the added time per 100 files dropped from 12.9s to 1.85s, close to the Obj-C framework’s 1.59s.
  • The incremental flag barely helps Swift export when everything is public (9-13%), but with internal code it saves 15-36%.
  • Also, the Xcode-integrated Obj-C build was itself slower than calling the link task directly at 400 public files (16.5s vs 13.5s for the same module and Kotlin version), but there was no difference with internal code (8.3s for both).

Takeaways

  • The article’s measurements reproduce well, and upgrading Kotlin (2.3.0 → 2.4.20 → 2.5.0-Beta1) doesn’t make much difference to iOS incremental build times.
  • Minimising the API exposed to Swift matters a lot more than module size or Kotlin version. Use internal for anything Swift doesn’t need to call, and only export() the modules whose API Swift actually uses.
  • It’s also worth enabling kotlin.incremental.native=true. With everything public it cut incremental build times by 24-37% here, and with internal code that went up to 53% for the 400 file module.
  • Swift export is currently a lot slower to build than the Obj-C framework, and a large exported API slows it down even more. If you’re trying it out, it’s well worth keeping that API small.

Some caveats….this is generated code with no library dependencies, run on a single machine with 3 runs per scenario (although the results were consistent between runs, usually within a few tenths of a second of each other). Real modules can’t make everything internal either: whatever Swift calls (view models, repositories, models) has to be public, so the real-world savings will depend on how much of a module is actually only used by other Kotlin code. Swift export is also still experimental, and those numbers are from a Kotlin beta, so that comparison in particular could change quickly.