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 Flow to Swift AsyncSequence directly, 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 async functions with cooperative cancellation (a cancelled Swift Task cancels the Kotlin coroutine; Kotlin cancellation surfaces as Swift CancellationError). For flows, a cancelled collection simply ends the for await loop rather than throwing.
  • Supported Flow types (Flow, SharedFlow, StateFlow, and mutable variants) convert to Swift classes that implement AsyncSequence, preserving type arguments where the bridge applies.
  • You can collect with for await and attach work to SwiftUI .task so leaving the screen cancels collection.

Limits to budget for:

  • Custom exceptions originating in a Flow cannot 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 Flow occurrence converts automatically (for example nested Flow in lists/maps, or some return positions).
  • Custom Flow subtypes 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 suspend functions export as Swift async functions.
  • kotlinx.coroutines flows export toward Swift AsyncSequence (docs show consuming via .asAsyncSequence()).
  • Default execution for exported suspend calls and asAsyncSequence uses Dispatchers.Default unless you switch context in Kotlin (for example withContext(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 embedAndSignAppleFrameworkForXcode must be replaced with embedSwiftExportForXcode.
  • 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

ApproachMaturity signalCancellationFlow → Swift streamsBest when
Default ObjC export onlyBuilt-inWeak / incomplete for real async useManual / awkwardPrototypes only
SKIEThird-party compiler pluginSupported (per SKIE)AsyncSequence wrappersLittle hand-written Swift code, SwiftUI async style
KMP-NativeCoroutinesFAQ: “tried-and-tested”Supported (per FAQ)Via library + Combine/RxSwift optionsCombine/RxSwift or annotation workflow
Swift exportAlpha (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 free suspend functions.
  • Immutable models and sealed result types that cross the boundary without custom exceptions thrown from Flow pipelines (especially relevant with SKIE's Flow bridge).
  • StateFlow / SharedFlow for state that SwiftUI will collect for the life of a screen.
  • Explicit scope ownership: start work from platform lifecycle (Android viewModelScope, iOS .task / structured Task), never GlobalScope in shared code.

Avoid:

  • Exposing raw Job handles as the only cancellation story.
  • Returning nested Flow<Flow<T>> or List<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

  1. Prove the failure mode. From SwiftUI, start a shared suspend call or Flow collection 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.
  2. 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.
  3. 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.
  4. Migrate call sites in slices: auth session, then primary read models, then write paths, keeping the app shippable.
  5. 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?
You can often call 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?
No. If the UI is Compose Multiplatform, more of the collection stays in Kotlin. If the UI is SwiftUI or UIKit talking to a shared Kotlin module, the Swift boundary still exists. Compose Multiplatform and Jetpack Compose are related but not identical toolkits; see JetBrains' relationship overview.
Should we wait for Swift export to stabilize?
For new spikes, maybe. JetBrains labels it Alpha with expected breaking changes and direct-integration constraints. Waiting can be rational; shipping shared features to iOS without cancellation is not. SKIE or KMP-NativeCoroutines cover production needs today, and Swift export is worth revisiting once its status and your integration style line up.
Will this fix Kotlin Multiplatform app size?
No. Size is a packaging and measurement problem. See Kotlin Multiplatform app size, or measure with Play Console and Xcode App Thinning reports.

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.

Sources