Unifying Asynchronous APIs: Bridging Swift Concurrency and Kotlin Coroutines in KMM
When building a modern mobile application with Kotlin Multiplatform Mobile (KMM), developers often face the challenge of reconciling two powerful yet distinct asynchronous paradigms: Swift Concurrency’s async/await with Task, and Kotlin’s coroutines with Flow and Deferred. Unifying Asynchronous APIs means exposing a single, intuitive async interface that works seamlessly on both iOS and Android, while preserving native cancellation, structured concurrency, and robust error handling. This article walks through the design, implementation, and best practices for creating that unified layer.
Why Unify? The Pain Points of Divergent Concurrency Models
Although Swift Concurrency and Kotlin Coroutines share conceptual similarities, they differ in syntax, lifecycle management, and platform‑specific quirks:
- Task vs. coroutine scope: Swift’s
Taskis a lightweight unit tied to the current actor context, whereas Kotlin scopes are explicit, often created withCoroutineScopeandDispatchers. - Cancellation propagation: Swift propagates cancellation via
Task.cancel()andwithTaskCancellationHandler, whereas Kotlin usesJob.cancel()andcoroutineContext[Job]. - Error semantics: Swift throws
Errorobjects, while Kotlin distinguishes betweenCancellationExceptionand otherThrowabletypes. - UI integration: Swift’s
MainActorand@MainActorannotations make UI thread guarantees explicit, but Kotlin requires manual switching toDispatchers.MaininsidewithContext.
When you maintain separate code paths for each platform, you duplicate effort, introduce subtle bugs, and make maintenance painful. A unified async API eliminates duplication, enforces consistent error handling, and simplifies debugging.
Design Principles for a Unified Async Layer
Below are guiding principles that shape the architecture of the unified layer:
1. Platform‑agnostic abstractions
Define interfaces that are agnostic to Swift or Kotlin. For example, a generic Result type can be mapped to Result in Swift and Result in Kotlin, both representing success or failure.
2. Structured concurrency everywhere
Ensure that all async work originates from a well‑defined parent job or task. This guarantees that cancellation is propagated correctly and resources are cleaned up consistently.
3. Transparent cancellation support
The unified API should expose a single cancel() method that works on both platforms, delegating to the underlying Task or Job internally.
4. Unified error propagation
Wrap platform‑specific exceptions in a shared DomainError sealed class, so that business logic can handle errors uniformly regardless of the host language.
5. Lazy execution and backpressure handling
Use Kotlin Flow for stream-like APIs, and expose a Publisher‑like abstraction in Swift that can be consumed via Combine or async streams.
Implementing the Unified Async API in KMM
Below is a step‑by‑step guide to creating a reusable AsyncService that abstracts both async/await and coroutines. The example focuses on a simple network request that fetches a list of articles.
1. Define the Common Domain Layer
In the shared module, create a data model and error type that both platforms can reference:
data class Article(val id: Int, val title: String, val content: String)
sealed class DomainError : Throwable() {
object Network : DomainError()
object Timeout : DomainError()
data class Unexpected(val cause: Throwable) : DomainError()
}
These classes will be compiled to both Kotlin and Swift (via Kotlin/Native).
2. Create the AsyncService Interface
The interface exposes a suspend function that returns Result. The same interface is visible to Swift code after code generation:
interface AsyncService {
suspend fun fetchArticles(): Result>
}
Because the interface uses only platform‑agnostic types, it can be implemented in Kotlin and consumed from Swift.
3. Implement the Service in Kotlin
Wrap the network call in a coroutine with proper cancellation and error mapping:
class ArticlesRepository(private val api: ArticlesApi) : AsyncService {
override suspend fun fetchArticles(): Result> {
return try {
val response = api.getArticles()
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(DomainError.Network)
}
} catch (e: IOException) {
Result.failure(DomainError.Network)
} catch (e: TimeoutCancellationException) {
Result.failure(DomainError.Timeout)
} catch (e: Throwable) {
Result.failure(DomainError.Unexpected(e))
}
}
}
Note how TimeoutCancellationException is caught separately to preserve cancellation semantics, while other exceptions are wrapped in DomainError.Unexpected.
4. Expose the Service to Swift
Generate the Swift bridge by adding the following to the build.gradle.kts:
kotlin {
ios()
iosSimulatorArm64()
// ...
sourceSets {
val iosMain by getting {
dependencies {
// ...
}
}
}
}
Once the module is compiled, Swift can import Shared and call the fetchArticles() method using async/await:
import Shared
@MainActor
func loadArticles() async {
let service: AsyncService = ArticlesRepository(api: NetworkApi())
do {
let result = try await service.fetchArticles()
switch result {
case .success(let articles):
// Update UI
case .failure(let error):
handleError(error)
}
} catch {
// Handle unexpected errors
}
}
5. Unified Cancellation Pattern
To cancel a request from Swift, you can keep a reference to the Task that calls fetchArticles():
@MainActor var fetchTask: Task? func startFetching() { fetchTask = Task { await loadArticles() } } func cancelFetching() { fetchTask?.cancel() }
On the Kotlin side, if you need to expose cancellation externally, wrap the coroutine in a CoroutineScope and provide a cancel() method that delegates to the underlying Job:
class CancellableArticlesRepository : AsyncService {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var job: Job? = null
override suspend fun fetchArticles(): Result> {
return withContext(scope.coroutineContext) {
job = this.coroutineContext[Job]
// ... same implementation as before
}
}
fun cancel() {
job?.cancel()
}
}
6. Mapping Domain Errors to UI‑Friendly Messages
Because Swift cannot directly pattern match Kotlin sealed classes, create a helper that translates DomainError into a human‑readable string:
func errorMessage(for error: DomainError) -> String {
switch error {
case .network:
return "Unable to reach the server. Please check your internet connection."
case .timeout:
return "The request timed out. Try again later."
case .unexpected(let cause):
return "An unexpected error occurred: \(cause.localizedDescription)"
}
}
Use this helper whenever you switch on the Result in Swift.
Testing the Unified Layer
Unit tests are essential to ensure that the unified API behaves consistently across platforms. Use the following strategies:
- Kotlin unit tests: Test
ArticlesRepositoryby mockingArticlesApiwithMockKorMockito-Kotlin, and verify error mapping. - Swift unit tests: Use XCTest to call
fetchArticles()insideasynctest methods, and assert on theResultvalue. - Integration tests: Spin up a local mock server (e.g., MockWebServer) and run the same code path on both iOS and Android to confirm end‑to‑end behavior.
Performance Considerations
While unifying APIs simplifies development, you should be mindful of overhead:
- Context switches: Each
withContext(Dispatchers.Main)in Kotlin orMainActorin Swift incurs a context switch. Keep UI work minimal inside these blocks. - Object creation: Avoid allocating large data structures on the main thread; perform heavy transformations inside
Dispatchers.IO. - Backpressure: For stream APIs, expose
Flowon Kotlin andAsyncSequenceon Swift. This allows each platform to consume the stream with backpressure control.
Common Pitfalls and How to Avoid Them
1. Forgetting to Cancel
Always tie the lifecycle of a request to a UI component (e.g., ViewModel or ViewController). In Swift, cancel the Task in deinit or when the view disappears. In Kotlin, cancel the Job in the onCleared() of a ViewModel.
2. Mixing Coroutines with Executors
Do not manually submit coroutines to ExecutorService. Stick to Kotlin’s dispatchers to maintain cancellation and exception handling guarantees.
3. Ignoring Backwards Compatibility
When evolving the API, keep the interface stable. Add new functions with default implementations or overloads instead of breaking existing contracts.
Real‑World Example: News App with Offline Caching
Imagine a news app that fetches articles, caches them locally, and supports pull‑to‑refresh. With the unified async layer, the repository can expose:
suspend fun getCachedArticles(): List<Article>suspend fun refreshArticles(): Result<List<Article>>
Both iOS and Android call refreshArticles() using async/await or coroutines, and handle errors uniformly. Caching logic lives entirely in the shared module, so any new feature (e.g., article bookmarking) can reuse the same infrastructure.
Future Directions
- Flow‑to‑AsyncSequence adapters: Create helper functions that convert
Flow<T>to Swift’sAsyncStream<T>, enabling native backpressure handling. - Cross‑platform testing framework: Use Kotlin’s
expect/actualmechanism to write tests that run on both JVM and Native. - Observability: Integrate with distributed tracing (e.g., OpenTelemetry) to trace requests across iOS and Android, exposing metrics on cancellation rates and error frequencies.
Conclusion
Unifying asynchronous APIs in KMM is not merely an architectural nicety; it’s a productivity engine that cuts duplication, reduces bugs, and streamlines maintenance. By defining a platform‑agnostic domain layer, exposing a consistent AsyncService interface, and carefully handling cancellation and error propagation, you empower your team to focus on business logic rather than plumbing differences between Swift Concurrency and Kotlin Coroutines.
Adopting this pattern will give your codebase the resilience to evolve, the clarity to debug, and the confidence to ship features faster across both mobile ecosystems.
Ready to simplify your async code? Try creating a unified AsyncService in your next KMM project and experience the difference firsthand.
