I build an app called Afterfade on my own. You record seven five-second videos over the course of a day, and the app turns them into a single lo-fi track. It runs on both Android and iOS, built with Kotlin Multiplatform (KMP) and Compose Multiplatform. Swift and AVFoundation were new to me. Implementation still took six days for Android and two days for iOS (previous post).
Then the video export hung forever on iOS, and the fix was to stop writing that part in Kotlin. If you are considering KMP, what you are probably worried about is exactly that: what happens when you hit something Kotlin cannot handle cleanly. It turned out to cost one folder of Swift. The UI, the music engine, and the Android version were untouched.
The video export never returned
After collecting seven clips, the app generates a track and exports a video stitching them together. That export never returned — no crash, nothing in the logs. The same flow worked fine on Android.
// the iOS export implementation at the time, the relevant part
suspendCancellableCoroutine { cont ->
exportSession.exportAsynchronouslyWithCompletionHandler {
cont.resume(exportSession.status == AVAssetExportSessionStatusCompleted)
}
cont.invokeOnCancellation { exportSession.cancelExport() }
}
AVAssetExportSession reports completion through a completion handler, so the call is wrapped in suspendCancellableCoroutine. When it does not work, the handler is never invoked, and the coroutine waits forever with no exception.
What I tried, and why none of it worked
I changed the dispatcher and wrapped the call in withTimeoutOrNull. Neither fixed the hang. I never worked out the root cause — my best guess is object lifetime — but it did not matter. The iOS export shared nothing with Android; it was iOS-specific AVMutableComposition and AVAssetExportSession while the Android version was entirely separate code on Media3 Transformer. Being able to call an API from Kotlin/Native is not a reason to do it there.
Two questions for deciding what goes to Swift
Since then, I ask two questions whenever I add something to the iOS side. Can Android reuse the same code? If not, there is nothing to share by writing it in Kotlin. Does the API call you back when it finishes? Bridging a completion handler into a coroutine works most of the time; when it does not, it fails silently. In either case, write it in Swift.
The hang happened because I answered the first question wrong.
NativeBridge: how Kotlin calls Swift
Swift can call into Kotlin, but the usual setup gives you nothing for the opposite direction. So the Kotlin side declares empty slots, and Swift injects the real implementations at startup as lambdas.
// iosMain (iOS-only Kotlin)
object NativeBridge {
var exportFilm: FilmExportFn? = null
var createPlayerView: PlayerViewFactory? = null
var extractAudio: AudioExtractFn? = null
}
The expect/actual entry points did not change, so all I swapped were the iOS-specific implementation files. Swift and Kotlin never land in the same file, and a change on either side cannot quietly alter the other without crossing a shared signature. Embedding into Compose is one call — UIKitView(factory = { NativeBridge.createPlayerView(...) }) with UIKitView.
How the iOS side ended up divided
Applying those questions: video composition, audio extraction, sharing, dominant colour extraction, and video playback all went to Swift — they either call back on completion or manage their own object lifetimes. The camera capture session stayed in Kotlin, because everything from AVCaptureSession's beginConfiguration() to commitConfiguration() completes as you call it.
On iOS too, 90% of the code is still Kotlin
Here is the current breakdown (measured September 2, 2026).
commonMain 9,011 androidMain 1,537 iosMain 1,017 Swift 1,199
Against those 12,764 lines, commonMain accounts for 70.6%. Swift is 9.4% — AVFoundation calls and NativeBridge registrations. The music engine is about 2,200 lines of plain Kotlin in commonMain, no external dependencies, no platform branches (the DSP layer is public as afterfade-dsp). The same input produces the same track on both platforms, verified down to bit-identical waveforms.
You do not need to work out how much will fit in Kotlin before choosing KMP. Whatever does not fit gets handed over afterwards, inside the platform-specific files. The worst case I hit was 1,199 lines of Swift, an untouched Android version, and a shared portion that went up rather than down.
Afterfade is being built for RevenueCat Shipaton 2026. I plan to write separately about the music engine and the UI.


Top comments (0)