Your suspend function becomes a callback in Swift, and cancellation does not survive
Swift cancels its Task. The Kotlin Job never hears about it, so the work keeps running.
TL;DR
Kotlin's suspend and Swift's async look like the same idea, and across the Objective-C bridge
they are not. A suspend function is exported as a function taking a completion handler.
Swift's async/await then wraps that handler back into an awaitable.
The wrapper is one-way. Cancelling the Swift Task cancels the Swift side of the await; the
Kotlin Job underneath has no reference to it and keeps running — writing to your database,
holding its memory, firing its analytics. Structured concurrency stops at the bridge.
If cancellation matters — and for anything tied to a screen it does — the cancel has to be an explicit part of the exported API.
The case this post is about
A shared search use case, called from SwiftUI as the user types.
// commonMain — exported to iOS
class SearchUseCase(private val api: SearchApi, private val db: SearchCache) {
suspend fun search(query: String): List<Result> {
val results = api.search(query) // network
db.store(query, results) // write
return results
}
}
Swift calls it the way the generated header invites:
// SearchViewModel.swift
@MainActor final class SearchViewModel: ObservableObject {
@Published var results: [Result] = []
private var task: Task<Void, Never>?
func onQueryChanged(_ q: String) {
task?.cancel() // debounce — looks correct
task = Task {
let r = try? await useCase.search(query: q)
if let r { self.results = r }
}
}
}
That is the standard debounce pattern and it reads as obviously right. task?.cancel() does stop
the previous await from delivering, so the UI behaves. The part you cannot see from Swift is
that the previous Kotlin coroutine is still going.
The symptom
Three observations that took a while to connect.
The cache held results for queries the user had abandoned. Type "kotlin multiplatform" and the
cache contains rows for k, ko, kot, kotl — every prefix, each written by a coroutine
Swift believed it had cancelled.
Request count scaled with keystrokes, not with queries. The API dashboard showed roughly one
search per character typed. Nothing in the Swift code issues those; every one was a coroutine
that outlived its Task.
Analytics logged a search_performed event per prefix. Which is what finally made it
undeniable — the shared code was running to completion, all the way to its last side effect, long
after Swift had moved on.
No crash. No warning. The one place it is visible from Swift is a breakpoint that never gets hit,
because CancellationException is never thrown.
Why the obvious fix failed
"Debounce in Swift before calling." Fewer calls, same defect. A debounce reduces how often you start work you cannot stop; it does not make the work stoppable. The first slow request still runs to completion after the user has typed past it.
"try? await is swallowing the cancellation." This was my first theory and it is wrong in an
instructive way. try? does discard the error — but there is no error to discard: the Kotlin side
never threw, because it was never cancelled. I spent an hour improving error handling on a path
that had no error on it.
"Check Task.isCancelled inside the Kotlin code." There is no such thing to check. The Kotlin
coroutine has its own isActive, driven by its own Job, which Swift's Task has no connection
to. Two independent cancellation systems with no bridge between them.
"Wrap the call in withTimeout on the Kotlin side." Bounds the damage and does not implement
the intent. "Stop because the user typed something else" is not "stop after N seconds".
"Expose a Flow instead and collect it." Correct instinct — the flow-based bridges
(SKIE, KMP-NativeCoroutines) do solve this properly, and if you use one, this whole post is
handled for you. Reaching for one mid-project is a real option; what follows is what the
mechanism is, which you need either way.
The actual mechanism
The export is a two-step translation, and cancellation is lost in the first step.
Step one: Kotlin → Objective-C. Objective-C has no suspend. The compiler exports a suspend
function as a method taking a completion handler:
// generated header, simplified
- (void)searchQuery:(NSString *)query
completionHandler:(void (^)(NSArray<Result *> * _Nullable, NSError * _Nullable))completion;
Note what is absent: any handle, token, or cancel method. The Kotlin Job exists — the coroutine
is launched into some scope on the Kotlin side — but nothing referencing it crosses the boundary.
Step two: Objective-C → Swift. Swift's importer sees a completion-handler method and
automatically re-presents it as async. That is a convenience over the callback, synthesised by
the compiler. It can only cancel what it owns: the continuation waiting for the callback.
So Task.cancel() marks the Swift task cancelled and stops the await from resuming. The
completion handler is still registered. The Kotlin coroutine still runs, still calls the handler
when it finishes, and the result is dropped on the floor — after every side effect has already
happened.

Stated plainly:
Structured concurrency is structured within one runtime. The bridge is a callback, and a callback has no cancellation channel. Anything you want cancellable across it must carry its own.
The fix
Make the cancellation handle part of the exported API. The shared code owns a scope; Swift gets
something it can call cancel() on.
// commonMain — exported with an explicit handle
class SearchController(private val useCase: SearchUseCase) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Returns a handle Swift can cancel. */
fun search(
query: String,
onResult: (List<Result>) -> Unit,
onError: (Throwable) -> Unit,
): Cancellable {
val job = scope.launch {
try {
onResult(useCase.search(query))
} catch (e: CancellationException) {
throw e // never report a cancel as an error
} catch (e: Throwable) {
onError(e)
}
}
return Cancellable { job.cancel() }
}
fun close() = scope.cancel()
}
/** A one-method class, because Obj-C cannot see a Kotlin `fun interface`'s lambda form. */
class Cancellable(private val onCancel: () -> Unit) {
fun cancel() = onCancel()
}
Swift now cancels the thing that is actually running:
@MainActor final class SearchViewModel: ObservableObject {
@Published var results: [Result] = []
private var inFlight: Cancellable?
func onQueryChanged(_ q: String) {
inFlight?.cancel() // ← reaches the Kotlin Job
inFlight = controller.search(
query: q,
onResult: { [weak self] r in Task { @MainActor in self?.results = r } },
onError: { [weak self] e in Task { @MainActor in self?.show(e) } }
)
}
deinit { controller.close() }
}
Three details that are easy to get wrong:
The shared code must be cancellation-cooperative. job.cancel() only takes effect at a
suspension point. A long non-suspending loop in shared code ignores it, exactly as it would in
Kotlin-only code — the bridge does not change that, so the usual ensureActive() discipline
still applies.
Re-throw CancellationException. Catching Throwable and routing it to onError turns every
cancel into a user-visible error. This is a Kotlin-side mistake that the bridge makes much more
visible, because cancellation is now frequent.
The callbacks arrive on a Kotlin dispatcher, not the main thread. Hopping to @MainActor in
Swift is not optional — and [weak self] matters because the Kotlin side holds these lambdas for
the lifetime of the job.
If you would rather not hand-write this per use case, that is what SKIE and
KMP-NativeCoroutines generate: SKIE exposes suspend functions as real Swift async with
cancellation wired through, and Flow as AsyncSequence. For a project with many exported
suspend functions, adopting one is the cheaper path. The handle pattern above is what they produce
for you, and what you need when you are exporting a small surface or cannot add the plugin.
How we proved it
The claim is about work that continues invisibly, so the evidence has to come from inside the Kotlin coroutine rather than from Swift.
A counter at the end of the shared function — after the write, where a cancelled coroutine should never reach — and a UI test that types eight characters with 50ms between keystrokes:
typing "kotlin!" (7 chars), one Task cancelled per keystroke
before: completions reaching the db write = 7 api calls = 7
after: completions reaching the db write = 1 api calls = 7
The second column is the honest part: the API calls do not drop, because cancellation lands after the request is in flight. What changes is that six of them no longer write to the cache, no longer log analytics, and no longer deliver. To cut the requests too you still need a debounce — which is now worth adding, because it is an optimisation rather than a disguise.
The cache content is the cleaner check:
before: SELECT query FROM cache → k, ko, kot, kotl, kotli, kotlin, kotlin!
after: SELECT query FROM cache → kotlin!
One honest limit: I verified this on the simulator and one device with the Obj-C export path. If
you export via SKIE, the generated Swift already carries cancellation, and the measurement above
should show one completion before you change anything — worth confirming rather than assuming,
since the two setups produce opposite results from identical Kotlin.
What this generalizes to
An FFI boundary is a callback, whatever sugar sits on top. Swift async over an Obj-C
completion handler, a JS promise over a wasm export, a JNI call from a Java thread — the sugar is
synthesised on one side and cannot carry semantics the boundary never transmitted. Ask what the
wire format is, not what the call looks like.
Cancellation is a capability, not a default. It propagates inside one structured-concurrency runtime and nowhere else. Crossing a boundary, it must be modelled explicitly — a handle, a token, a cancel method — or it silently does not exist.
Side effects make orphaned work expensive. A cancelled-but-running computation that returns a dropped value costs CPU. One that writes to a database, logs analytics, and consumes quota corrupts state. Put the side effects where cancellation can still prevent them, late in the function and after a suspension point.
Trust the behaviour you can measure from the other side. Swift showed correct UI, no errors, and cancelled tasks — a complete and reassuring picture that was wrong. The counter inside the Kotlin function was the only thing that could see the truth. When two runtimes meet, instrument both.
Related
- Your Kotlin class becomes an Objective-C class, which is why the name is wrong — what else the export flattens
- A platform bridge is an interface with a lifetime problem — the same gap, from the Android side
- expect/actual is not an interface, and the difference shows up at link time — compile-time vs runtime resolution
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".