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 and Compose Multiplatform. Implementation took six days for Android and two days for iOS.
This post is about a bug I hit during development, and how it led me to a rule for deciding how much of the iOS side to write in Kotlin and where to hand off to Swift.
One thing worth knowing up front: I had never built an iOS app before. Swift and AVFoundation were both new to me on this project. The reason I could still ship the iOS version in two days is that the UI and the music engine were already shared with Android.
The video export never returned
Once seven five-second videos have been collected, the app generates a track and then exports a single video that stitches the clips together in time with it. That export never returned.
While it runs, the app shows a loading state, and it simply stayed there. No crash, nothing in the logs. The same flow worked fine on Android, and it only reproduced on iOS. I will refer to this state, where a process never returns and the app sits waiting forever, as a hang.
The part that hung is shown in red below. Green is code shared by both platforms, blue and orange are code specific to each one.
The export is not shared between the two platforms. Here is what the iOS side looked like at the time.
suspend fun generateFilm(...): Boolean = withContext(Dispatchers.IO) {
// ... build the AVMutableComposition ...
val exportSession = AVAssetExportSession(
asset = composition,
presetName = AVAssetExportPresetMediumQuality,
) ?: return@withContext false
exportSession.outputURL = outputUrl
exportSession.outputFileType = AVFileTypeMPEG4
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 to expose it as a Kotlin suspend function. This seems to be the common way to bridge a native async API from Kotlin/Native, and it apparently works most of the time.
When it does not work, the completion handler is never invoked. cont.resume(...) is never reached, so the coroutine waits forever. No exception is thrown either, which left me with no indication of where it had stopped.
What I tried, and why none of it worked
I suspected the dispatcher first. I switched from Dispatchers.IO to Dispatchers.Main, and I tried pushing the work onto the main thread explicitly with dispatch_async(dispatch_get_main_queue()). The behaviour changed, but the hang did not go away.
Then I added a timeout. Wrapping the call in withTimeoutOrNull should at least have prevented it from waiting forever. That did not work either. The export still never came back with the timeout in place.
In the end I stopped using the completion handler altogether and polled exportSession.status every 200 milliseconds instead. That worked.
It is a poor fix, though. It throws away the completion callback the API already provides and checks the state on a timer instead, which means up to 200 milliseconds of delay after the export finishes, and it also makes the failure reason harder to surface.
The same thing takes a few lines in Swift with async/await. Even without any iOS experience, it is the kind of code you can get working by following the sample in the documentation. Instead I had 30 lines of Kotlin that were not receiving the completion callback correctly.
I never worked out why the completion handler was not invoked, or why withTimeoutOrNull had no effect. I stopped chasing the cause there and went looking at something else instead.
Why did I pick Kotlin Multiplatform in the first place?
The question I should have been asking was not how to fix it, but why this code was in Kotlin at all.
The reason to use Kotlin Multiplatform is to share code. So I went back and checked what FilmGenerator.ios.kt actually shared with Android.
It shared nothing. The file was iOS-specific work built on AVMutableComposition and AVAssetExportSession, while the Android version was entirely separate code built on Media3 Transformer. The only thing the two had in common was the function signature.
I had put code that cannot be shared on top of the machinery for sharing it. There was no benefit to gain, and what I was left with was the overhead of cinterop, the mechanism that lets Kotlin call C and Objective-C APIs, plus bugs caused by the two languages disagreeing about how asynchronous work should be awaited.
Whether Kotlin/Native can call an API is not a reason to write it in Kotlin. That was my mistake here.
Two questions for deciding what goes to Swift
Since then, I ask myself these two questions in order whenever I add something to the iOS side. They live in the project's rules file.
1. Can Android reuse the same code?
If it cannot, there is nothing to share by writing it in Kotlin, and all that remains is the cost of reaching iOS APIs from Kotlin. If it cannot be reused, write it in Swift.
2. Does the API call you back when it finishes?
Many iOS APIs return control immediately and call you back later through a completion handler, a delegate, or GCD. That model and the way Kotlin coroutines wait are built on different assumptions, and when they fail to line up you get a silent hang like this one. If the API calls you back, write it in Swift.
The first question settles it most of the time. The second is there to catch what the first one misses, and that is exactly what happened with the hang above: I missed the first question, and wrote something matching the second without noticing.
There are two more kinds of work that go to Swift without needing a question. One is APIs like CoreGraphics, where you release objects with CFRelease once you are done; Kotlin manages memory for you by default, so mixing manual release into it means tracking leaks and double frees by hand. The other is an iOS UI component itself, a UIView subclass such as a video player or a camera preview, which is simpler to build in Swift and embed from Compose.
NativeBridge: how Kotlin calls Swift
Handing work to Swift is not as simple as calling a Swift function from Kotlin, because in the usual setup that direction does not exist. In KMP, Swift depends on the Kotlin framework and not the other way around.
So the Kotlin side declares empty slots to be filled in later, and Swift injects the real implementations when the app starts. What goes into those slots is not a value but the work itself, as a lambda.
// composeApp/src/iosMain/.../bridge/NativeBridge.kt
typealias FilmExportFn = (
clipPaths: List<String>,
// ... the rest of the export arguments ...
completion: (Boolean) -> Unit,
) -> Unit
object NativeBridge {
var exportFilm: FilmExportFn? = null
var createPlayerView: PlayerViewFactory? = null
var extractAudio: AudioExtractFn? = null
// ...
}
// iosApp/iosApp/iOSApp.swift
private func setupNativeBridge() {
let bridge = NativeBridge.shared
bridge.createPlayerView = { path, gravity, startSec, endSec, muted, pixelate in
LoopingPlayerView(path: path, gravity: AVLayerVideoGravity(rawValue: gravity), ...)
}
// ...
}
The expect/actual interfaces did not change. What changed is the body of each actual, which now calls a NativeBridge lambda instead of reaching into AVFoundation.
// FilmGenerator.ios.kt today, 37 lines in total
val exportFilm = NativeBridge.exportFilm ?: return false
return suspendCancellableCoroutine { cont ->
exportFilm(expandedPaths, expandedStarts, trackPath, bpm, outputPath) { success ->
cont.resume(success)
}
}
A 108-line file became 37 lines, and no AVFoundation API name appears in it any more. Embedding into Compose works the same way: UIKitView(factory = { NativeBridge.createPlayerView(...) }). The UI layer stays in Kotlin and does not change at all.
What it looks like in Swift
// iosApp/iosApp/FilmExporter.swift
// give up after 60 seconds; this waits without blocking a thread
let watchdog = Task {
try await Task.sleep(nanoseconds: 60 * NSEC_PER_SEC)
session.cancelExport()
}
await withCheckedContinuation { continuation in
session.exportAsynchronously {
continuation.resume()
}
}
watchdog.cancel()
Structurally this is the same as the Kotlin version: a completion handler wrapped in a continuation. The difference is that here it is the usage the language and its runtime are designed around.
The timeout is also straightforward to express with Task. Because Task.sleep waits without occupying a thread, everything else keeps running while it waits. The problem I had with withTimeoutOrNull on the Kotlin side went away with it. The hang has not come back since the migration.
How the iOS side ended up divided
Applying those questions, the iOS-specific work landed like this. The Android side is mostly shared code already, so the only side that needs sorting is iOS.
| Feature | Written in | Reason |
|---|---|---|
| Video composition and export | Swift | Calls back on completion |
| Audio extraction (AVAssetReader + CoreMedia) | Swift | Track loading calls back on completion |
| Dominant colour extraction (CoreGraphics) | Swift | You release the objects yourself |
| Video playback (AVPlayer + AVPlayerLayer) | Swift | The UI component itself |
| Sharing and saving to Photos | Swift | Calls back on completion |
| The camera capture session | Kotlin | Everything completes as you call it |
That last row needs a little explanation.
The camera capture session is still in Kotlin. AVCaptureSession, AVCaptureDeviceInput and AVCaptureMovieFileOutput are all set up in CaptureScreen.ios.kt.
fun setupSession() {
session.beginConfiguration()
// ... swap the input device and the outputs ...
if (session.canSetSessionPreset(AVCaptureSessionPreset1920x1080)) {
session.setSessionPreset(AVCaptureSessionPreset1920x1080)
}
session.commitConfiguration()
}
I did not land on "move everything that touches AVFoundation to Swift" because this part is entirely synchronous. Everything from beginConfiguration() to commitConfiguration() completes as you call it, and there is no asynchronous completion to wait for.
The recording-finished callback (AVCaptureFileOutputRecordingDelegateProtocol) does call back, but it stays in Kotlin too. What makes a callback API dangerous is suspending a coroutine until the callback arrives. This one is not awaited that way; it only updates screen state when it fires, so it works fine in Kotlin.
What I hand to Swift is only the UIView that hosts the AVCaptureVideoPreviewLayer, which is the UI component itself.
The line is drawn per API behaviour, not per framework. Within the same AVFoundation, work that completes synchronously stays in Kotlin, and work that waits for a callback goes to Swift.
Share the right things, not the most lines
After the migration, the Swift code came to 1,251 lines. commonMain is 6,049 lines, which puts the shared portion at 48.7%. Had I kept everything in Kotlin, that number would look better.
Since then a larger refactor moved the screens and the generation flow into commonMain, and the shared portion is up to 64.6% (measured August 28, 2026). The line between Kotlin and Swift is still where this post leaves it; what got redrawn was the boundary inside Kotlin. I will write separately about what moved where.
I would argue that the value of Kotlin Multiplatform is not in how many lines you shared, but in whether the things worth sharing are actually shared.
In this app, the things worth sharing were the music engine and the UI, not the way a video export gets invoked. The engine is plain Kotlin in commonMain, about 2,200 lines, with no external dependencies. It runs on both platforms without a single platform branch, and the same input produces the same track on either one.
It is also why someone with no iOS experience could ship the iOS version in two days. Most of it did not have to be written. The part that did was fastest to write in Swift, and gave me the least trouble.
Afterfade is being built for RevenueCat Shipaton 2026. I plan to write separately about the music engine and the UI.
- App Store: https://apps.apple.com/us/app/afterfade/id6800247416
- Google Play: coming soon...




Top comments (0)