Engineering

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.

MobileByteSensei 8 min read
kmpioscoroutines

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.

A suspend function crossing two translations: Kotlin exports it as an Objective-C completion-handler method, carrying no job handle; Swift's importer wraps that handler back into async. Cancelling the Swift Task cancels only the Swift continuation — the Kotlin Job it never received a reference to keeps running to its last side effect.

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.

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".

Follow by RSS