Kotlin/Native Build Performance
Turn "the iOS build is slow" into a measured diagnosis and a small set of safe
fixes. Two rules apply throughout:
- Never trade away required release behavior. A faster local loop must not
change what CI publishes.
- Measure before and after with the same command and the same build state.
An unmeasured fix is a guess.
Step 0: Classify the Slow Scenario
Establish four facts before editing anything: where (local or CI),
what (debug feedback loop or release/distribution artifact), state
(first build, clean, warm, or no-op), and phase (which tasks dominate the
log). Then match the dominant symptom:
| Symptom in the build log |
Likely cause |
Read |
linkRelease* or *ReleaseXCFramework tasks in a local development loop |
Building distribution artifacts for development |
artifacts-and-targets |
| Kotlin/Native compiler distribution downloaded on every CI run |
~/.konan not preserved between runs |
caching-and-gradle |
| Long pause before the first task starts |
Configuration phase, no configuration cache |
caching-and-gradle |
| All iOS targets build when only one simulator is needed |
Broad task (build, assemble, assemble*XCFramework) or unused targets |
artifacts-and-targets |
ksp* tasks ahead of compileKotlinIos* |
Generated-code work on the native path |
exports-and-generated-code |
| Small source edit recompiles and relinks everything |
Compiler caches disabled, or missing incrementality |
caching-and-gradle, experimental |
Machine overloaded while several link* tasks run at once |
Parallel native linking |
caching-and-gradle, worker-limit caveat |
Step 1: Audit and Measure
Run the static audit from the project root:
scripts/audit-native-build.sh /path/to/project
It is read-only and prints file:line findings (disabled caches, broad
local tasks, transitiveExport, broad KSP configuration, missing CI
.konan cache), each pointing at the reference file with the fix.
Findings are leads, not verdicts — confirm each against project policy.
Find the command the user actually waits for: a script, a CI step, or the
Gradle invocation inside an Xcode build phase. Optimize that command, not
a task you picked yourself.
Run it twice when practical. The first build downloads Kotlin/Native
components and fills caches; only the second and later runs are
representative. Attribute time per task before blaming the compiler:
kotlin.build.report.output=file # writes build/reports/kotlin-build/
Gradle's --scan or --profile work too.
If you cannot run the build (no macOS host, no Xcode), analyze logs, build
scans, or checked-in metrics instead — and state explicitly that the
conclusion is static.
Step 2: Fix in Safe Order
Apply fixes one at a time, re-measuring as you go:
- Restore healthy defaults — remove cache/daemon workarounds, enable
Gradle build and configuration caches, keep
~/.konan warm in CI, update
Kotlin: references/caching-and-gradle.md
- Build only what the feedback loop needs — one specific task per loop,
correct integration method, justified target matrix:
references/artifacts-and-targets.md
- Cut export and generated-code cost — drop
transitiveExport, narrow
export(...), scope KSP work to the native compilations that need it:
references/exports-and-generated-code.md
- Experimental switches last, with the user's agreement:
references/experimental.md
Worked Example
A developer on an Apple Silicon Mac complains that "every shared-module
change costs 12 minutes". Their loop runs ./gradlew :shared:assembleXCFramework.
A build scan of the second (warm) run shows:
:shared:linkReleaseFrameworkIosArm64 348s
:shared:linkReleaseFrameworkIosX64 341s
:shared:compileKotlinIosX64 96s
:shared:linkDebugFrameworkIosSimulatorArm64 41s
:shared:compileKotlinIosSimulatorArm64 38s
configuration phase 64s
Reasoning chain:
- The loop is local + debug + warm, but ~690s goes to
linkRelease* —
release linking is an order of magnitude slower than debug and only CI
needs it. Replace the local command with
:shared:linkDebugFrameworkIosSimulatorArm64 (or the Xcode embed task if
Xcode drives the build). (artifacts-and-targets)
- All
iosX64 work serves Intel simulators; ask whether the team still
supports them before removing the target. (artifacts-and-targets)
- 64s of configuration on every run disappears behind
org.gradle.configuration-cache=true once trialed. (caching-and-gradle)
- Expected loop after the change: ~40s compile + ~40s link on warm builds —
confirm by re-running the new command twice and comparing.
- CI keeps
assembleXCFramework untouched; note that explicitly in the
report.
Verify
Report Your Changes
Close with a short performance note:
- The slow scenario (local/CI, debug/release, cold/warm) and the measured
evidence — or a statement that the analysis was static.
- Each change, and why it is safe for release behavior.
- The before/after commands the user can run to confirm the win.
- Remaining tradeoffs: experimental flags enabled, targets removed under a
policy assumption, worker limits, or generated-code work deferred.
- Links to the relevant official documentation below.
Official Documentation
1---2name: kotlin-tooling-native-build-performance3description: Diagnoses and fixes slow Kotlin/Native compilation and linking in Kotlin Multiplatform projects that target iOS. Use when the user reports slow iOS or shared-framework builds, long linkDebug*/linkRelease* or XCFramework tasks, cold CI builds that re-download the Kotlin/Native toolchain, KSP or other generated code on the native path, transitiveExport usage, or asks for a local-development versus CI build performance plan.4license: Apache-2.05---6
7# Kotlin/Native Build Performance
8
9Turn "the iOS build is slow" into a measured diagnosis and a small set of safe
10fixes. Two rules apply throughout:
11
121. Never trade away required release behavior. A faster local loop must not
13 change what CI publishes.
142. Measure before and after with the same command and the same build state.
15 An unmeasured fix is a guess.
16
17## Step 0: Classify the Slow Scenario
18
19Establish four facts before editing anything: **where** (local or CI),
20**what** (debug feedback loop or release/distribution artifact), **state**
21(first build, clean, warm, or no-op), and **phase** (which tasks dominate the
22log). Then match the dominant symptom:
23
24| Symptom in the build log | Likely cause | Read |
25|---|---|---|
26| `linkRelease*` or `*ReleaseXCFramework` tasks in a local development loop | Building distribution artifacts for development | [artifacts-and-targets](references/artifacts-and-targets.md) |
27| Kotlin/Native compiler distribution downloaded on every CI run | `~/.konan` not preserved between runs | [caching-and-gradle](references/caching-and-gradle.md) |
28| Long pause before the first task starts | Configuration phase, no configuration cache | [caching-and-gradle](references/caching-and-gradle.md) |
29| All iOS targets build when only one simulator is needed | Broad task (`build`, `assemble`, `assemble*XCFramework`) or unused targets | [artifacts-and-targets](references/artifacts-and-targets.md) |
30| `ksp*` tasks ahead of `compileKotlinIos*` | Generated-code work on the native path | [exports-and-generated-code](references/exports-and-generated-code.md) |
31| Small source edit recompiles and relinks everything | Compiler caches disabled, or missing incrementality | [caching-and-gradle](references/caching-and-gradle.md), [experimental](references/experimental.md) |
32| Machine overloaded while several `link*` tasks run at once | Parallel native linking | [caching-and-gradle](references/caching-and-gradle.md), worker-limit caveat |
33
34## Step 1: Audit and Measure
35
361. Run the static audit from the project root:
37
38 ```bash
39 scripts/audit-native-build.sh /path/to/project
40 ```
41
42 It is read-only and prints `file:line` findings (disabled caches, broad
43 local tasks, `transitiveExport`, broad KSP configuration, missing CI
44 `.konan` cache), each pointing at the reference file with the fix.
45 Findings are leads, not verdicts — confirm each against project policy.
462. Find the command the user actually waits for: a script, a CI step, or the
47 Gradle invocation inside an Xcode build phase. Optimize that command, not
48 a task you picked yourself.
493. Run it twice when practical. The first build downloads Kotlin/Native
50 components and fills caches; only the second and later runs are
51 representative. Attribute time per task before blaming the compiler:
52
53 ```properties
54 kotlin.build.report.output=file # writes build/reports/kotlin-build/
55 ```
56
57 Gradle's `--scan` or `--profile` work too.
584. If you cannot run the build (no macOS host, no Xcode), analyze logs, build
59 scans, or checked-in metrics instead — and state explicitly that the
60 conclusion is static.
61
62## Step 2: Fix in Safe Order
63
64Apply fixes one at a time, re-measuring as you go:
65
661. **Restore healthy defaults** — remove cache/daemon workarounds, enable
67 Gradle build and configuration caches, keep `~/.konan` warm in CI, update
68 Kotlin: [references/caching-and-gradle.md](references/caching-and-gradle.md)
692. **Build only what the feedback loop needs** — one specific task per loop,
70 correct integration method, justified target matrix:
71 [references/artifacts-and-targets.md](references/artifacts-and-targets.md)
723. **Cut export and generated-code cost** — drop `transitiveExport`, narrow
73 `export(...)`, scope KSP work to the native compilations that need it:
74 [references/exports-and-generated-code.md](references/exports-and-generated-code.md)
754. **Experimental switches last, with the user's agreement**:
76 [references/experimental.md](references/experimental.md)
77
78## Worked Example
79
80A developer on an Apple Silicon Mac complains that "every shared-module
81change costs 12 minutes". Their loop runs `./gradlew :shared:assembleXCFramework`.
82A build scan of the second (warm) run shows:
83
84```
85:shared:linkReleaseFrameworkIosArm64 348s
86:shared:linkReleaseFrameworkIosX64 341s
87:shared:compileKotlinIosX64 96s
88:shared:linkDebugFrameworkIosSimulatorArm64 41s
89:shared:compileKotlinIosSimulatorArm64 38s
90configuration phase 64s
91```
92
93Reasoning chain:
94
95- The loop is **local + debug + warm**, but ~690s goes to `linkRelease*` —
96 release linking is an order of magnitude slower than debug and only CI
97 needs it. Replace the local command with
98 `:shared:linkDebugFrameworkIosSimulatorArm64` (or the Xcode embed task if
99 Xcode drives the build). *(artifacts-and-targets)*
100- All `iosX64` work serves Intel simulators; ask whether the team still
101 supports them before removing the target. *(artifacts-and-targets)*
102- 64s of configuration on every run disappears behind
103 `org.gradle.configuration-cache=true` once trialed. *(caching-and-gradle)*
104- Expected loop after the change: ~40s compile + ~40s link on warm builds —
105 confirm by re-running the new command twice and comparing.
106- CI keeps `assembleXCFramework` untouched; note that explicitly in the
107 report.
108
109## Verify
110
111- [ ] Re-run the exact baseline command; compare warm build against warm
112 build, not warm against cold.
113- [ ] Second run with the configuration cache reports it is being reused.
114- [ ] The local development log no longer contains `linkRelease*`,
115 `*ReleaseXCFramework`, or removed generator tasks.
116- [ ] CI still produces every required release artifact, unchanged.
117- [ ] Tests pass and the app still runs from Xcode.
118- [ ] `scripts/audit-native-build.sh` reports no findings you have not
119 consciously accepted and documented.
120
121## Report Your Changes
122
123Close with a short performance note:
124
125- The slow scenario (local/CI, debug/release, cold/warm) and the measured
126 evidence — or a statement that the analysis was static.
127- Each change, and why it is safe for release behavior.
128- The before/after commands the user can run to confirm the win.
129- Remaining tradeoffs: experimental flags enabled, targets removed under a
130 policy assumption, worker limits, or generated-code work deferred.
131- Links to the relevant official documentation below.
132
133## Official Documentation
134
135| Topic | Link |
136|---|---|
137| Improving Kotlin/Native compilation time | https://kotlinlang.org/docs/native-improving-compilation-time.html |
138| Kotlin Gradle plugin compilation and caches | https://kotlinlang.org/docs/gradle-compilation-and-caches.html |
139| iOS integration methods | https://kotlinlang.org/docs/multiplatform-ios-integration-overview.html |
140| Direct integration with Xcode | https://kotlinlang.org/docs/multiplatform/multiplatform-direct-integration.html |
141| Building final native binaries and XCFrameworks | https://kotlinlang.org/docs/multiplatform/multiplatform-build-native-binaries.html |
142| Kotlin/Native binary options | https://kotlinlang.org/docs/native-binary-options.html |
143| KSP with Kotlin Multiplatform | https://kotlinlang.org/docs/ksp-multiplatform.html |