Published October 2026 by Batteries Included. Last verified: October 6, 2026
Your Android side collects a StateFlow in a ViewModel and cancels when the screen leaves. Your iOS side calls the same shared Kotlin API with Swift async/await, the user pops the screen, and the Kotlin work keeps running. Or a Flow shows up in Swift as a nameless interface with erased generics, and the team writes wrappers that leak collectors.
That is not a SwiftUI bug. It is the Swift boundary.
Kotlin Multiplatform lets you share coroutines and Flow in commonMain. Calling that code from Kotlin is well documented. Calling it from Swift is a different problem. JetBrains' Kotlin Multiplatform FAQ states that calling suspending functions and flows from Swift requires more work, and points to issue KT-47610. The FAQ's current guidance is to use plugins and libraries such as KMP-NativeCoroutines or SKIE, both of which support proper coroutine cancellation.
Claims below are attributed to JetBrains documentation, Touchlab's SKIE docs, and the KMP-NativeCoroutines project. Tool status changes quickly, so check the linked pages for the current state before making an architecture decision.
What breaks at the default Swift / Objective-C boundary
Suspending functions become completion handlers
Kotlin's Swift/Objective-C interop documentation states that suspending functions are presented in generated Objective-C headers as functions with callbacks (completion handlers). Starting with Swift 5.5, those functions are also callable from Swift as async functions without writing the completion handler by hand. The same docs call that functionality highly experimental and point to known limitations.
Touchlab's SKIE suspend documentation describes the practical failure mode: Objective-C has no suspend equivalent, so the export uses callbacks, and Swift async/await against that surface is just a wrapper around callbacks that do not support cancellation the way a real Swift async function does. SKIE generates actual Swift async functions and a runtime that bridges cancellation both ways. The KMP-NativeCoroutines README makes the same point independently: async against a completion handler is only a wrapper and still has no cancellation support.
JetBrains' ARC integration notes also warn that when you call Kotlin suspending functions from Swift, completion handlers might run on threads other than the main thread. If shared code or the Swift UI layer assumes “always main,” the result is subtle races, not a compile error.
In practice, treat “it compiles with await” as a demo milestone, not a production milestone. Production means a cancelled Swift Task stops the Kotlin work, and thread expectations are explicit on both sides.
Flow does not become AsyncSequence by itself
SKIE's Flows documentation states that Flow is a regular Kotlin interface, Swift's reactive stream type is AsyncSequence, and the two are incompatible: there is no direct interop and no easy manual cast. Without a bridge, generics are lost to Objective-C type erasure, wrappers multiply, and cancellation is easy to miss when the SwiftUI .task ends.
That is why a shared StateFlow<UiState> that feels natural on Android can feel hostile on iOS until a conversion strategy is chosen.
Three ways to bridge the gap
JetBrains' Multiplatform FAQ currently frames the choice like this:
- SKIE is described as easier to set up and less verbose; for example, it can map Kotlin
Flowto SwiftAsyncSequencedirectly, also with proper cancellation. - KMP-NativeCoroutines is described as the more tried-and-tested solution, with support for Swift
async/await, Combine, and RxSwift, plus proper cancellation.
A third path is JetBrains' own Swift export (Alpha): direct Swift modules without Objective-C headers, with documented concurrency mappings. None of these replace good API design. They change what Swift sees and whether cancellation works.
1. SKIE (Touchlab compiler plugin)
What the SKIE docs describe:
- Suspend functions become real Swift
asyncfunctions with cooperative cancellation (a cancelled SwiftTaskcancels the Kotlin coroutine; Kotlin cancellation surfaces as SwiftCancellationError). For flows, a cancelled collection simply ends thefor awaitloop rather than throwing. - Supported
Flowtypes (Flow,SharedFlow,StateFlow, and mutable variants) convert to Swift classes that implementAsyncSequence, preserving type arguments where the bridge applies. - You can collect with
for awaitand attach work to SwiftUI.taskso leaving the screen cancels collection.
Limits to budget for:
- Custom exceptions originating in a
Flowcannot be propagated to Swift and will cause a runtime crash. Model errors as typed results or sealed classes instead of throwing across the boundary. - Not every
Flowoccurrence converts automatically (for example nestedFlowin lists/maps, or some return positions). - Custom
Flowsubtypes are not supported. - Suspend members on generic classes may need SKIE's alternate call syntax (
skie(receiver).foo()).
Fit: projects using direct Kotlin/Native framework integration that want low Swift boilerplate and are willing to adopt a compiler plugin as part of the iOS toolchain. Because SKIE is a compiler plugin, it only works with compatible Kotlin versions (its docs currently list 2.0.0 through 2.4.10), so check compatibility before upgrading Kotlin.
2. KMP-NativeCoroutines
JetBrains' FAQ recommends it as a primary approach for calling suspending functions and flows from Swift, notes Combine and RxSwift support, and calls it more tried-and-tested than SKIE at the time of that wording.
The general workflow is to annotate shared APIs (for example @NativeCoroutines) so the plugin generates Swift-friendly wrappers. iOS code then calls the generated async or publisher APIs instead of raw Objective-C completion handlers. Verify current setup steps and annotation names against the KMP-NativeCoroutines repository, since plugin APIs evolve.
Fit: projects that need Combine or RxSwift, prefer an annotation-driven surface, or already standardized on this library.
3. Swift export (JetBrains, Alpha)
JetBrains' Swift export documentation states that Swift export is currently in Alpha, still incomplete, and that breaking changes are expected. Documented concurrency behavior when enabled:
- Kotlin
suspendfunctions export as Swiftasyncfunctions. kotlinx.coroutinesflows export toward SwiftAsyncSequence(docs show consuming via.asAsyncSequence()).- Default execution for exported suspend calls and
asAsyncSequenceusesDispatchers.Defaultunless you switch context in Kotlin (for examplewithContext(Dispatchers.Main)).
Hard constraints from the same docs:
- Works only with direct integration to the Xcode project (the usual template/wizard setup), not every remote XCFramework distribution style.
- The Xcode run-script task
embedAndSignAppleFrameworkForXcodemust be replaced withembedSwiftExportForXcode. - Other limitations still apply (for example generics erasure to upper bounds, no cross-language inheritance, collection-inheritor edge cases).
Fit: greenfield experiments, spikes, and teams that can absorb API churn and keep a rollback path. Teams shipping production apps should avoid making Alpha Swift export their only bridge unless they accept upgrade risk and keep the Swift-facing API behind a stable wrapper module.
Quick comparison
| Approach | Maturity signal | Cancellation | Flow → Swift streams | Best when |
|---|---|---|---|---|
| Default ObjC export only | Built-in | Weak / incomplete for real async use | Manual / awkward | Prototypes only |
| SKIE | Third-party compiler plugin | Supported (per SKIE) | AsyncSequence wrappers | Little hand-written Swift code, SwiftUI async style |
| KMP-NativeCoroutines | FAQ: “tried-and-tested” | Supported (per FAQ) | Via library + Combine/RxSwift options | Combine/RxSwift or annotation workflow |
| Swift export | Alpha (JetBrains) | Documented async / AsyncSequence path | .asAsyncSequence() | Experiments with direct integration |
Statuses change. Re-read the FAQ and the Swift export page before locking an architecture decision.
Designing the shared API so the bridge stays thin
Interop tools fix calling conventions. They do not fix a noisy shared surface.
Prefer:
- Small facade types aimed at UI features (
AccountRepository,SessionController), not dozens of freesuspendfunctions. - Immutable models and sealed result types that cross the boundary without custom exceptions thrown from
Flowpipelines (especially relevant with SKIE's Flow bridge). StateFlow/SharedFlowfor state that SwiftUI will collect for the life of a screen.- Explicit scope ownership: start work from platform lifecycle (Android
viewModelScope, iOS.task/ structuredTask), neverGlobalScopein shared code.
Avoid:
- Exposing raw
Jobhandles as the only cancellation story. - Returning nested
Flow<Flow<T>>orList<Flow<T>>to Swift without a plan (SKIE documents automatic conversion gaps here). - Assuming main-thread callbacks from default completion-handler interop.
- Letting every feature invent its own Swift wrapper. Use one bridge strategy per app.
Before moving more domain code into commonMain, inventory every Swift call site that touches suspend or Flow, classify its cancellation behavior, and pick one bridge. Expanding shared logic while iOS still uses ad hoc wrappers multiplies cleanup cost.
A practical adoption sequence
- Prove the failure mode. From SwiftUI, start a shared
suspendcall orFlowcollection inside.task, leave the screen, and confirm whether Kotlin work stops (network, timers, logging). If it does not, the bug will show up for users as battery drain and session issues. - Choose one bridge (SKIE, KMP-NativeCoroutines, or a time-boxed Swift export spike) and document it in the repo README so new features do not invent a second pattern.
- Wrap the shared module behind a narrow Swift package or thin adapter if you may need to swap bridges later, especially when trying Alpha Swift export.
- Migrate call sites in slices: auth session, then primary read models, then write paths, keeping the app shippable.
- Add regression checks: cancellation tests around screen teardown, and thread assertions where UI mutation must stay on main.
FAQ
Common questions
Can we just use Swift async against the default framework export?
suspend with await because of completion-handler interop. Kotlin's own interop docs mark that path as highly experimental with limitations, and SKIE documents that callback-based async does not give real cancellation semantics. For a shipping app, plan on an explicit bridge.Does Compose Multiplatform remove this problem?
Should we wait for Swift export to stabilize?
Will this fix Kotlin Multiplatform app size?
Shipping a shared Kotlin module to iOS?
We'll review your shared API surface and the Swift boundary, then recommend a bridge that keeps cancellation and threading safe. Still deciding on scope? A Discovery Roadmap turns this into a plan your team can act on, or see our KMP development and migration services.
Related
Sources
- Kotlin Multiplatform FAQ: concurrent code and Swift
- Kotlin: Interoperability with Swift/Objective-C
- Kotlin: Integration with Swift/Objective-C ARC
- Kotlin: Interoperability with Swift using Swift export (Alpha)
- YouTrack KT-47610
- SKIE: Suspend functions
- SKIE: Flows
- KMP-NativeCoroutines
- Kotlin: Compose Multiplatform and Jetpack Compose