Let's Make a Callback Suspend: suspendCancellableCoroutine, Line by Line

Wrapping a callback API in a suspend function is four lines. The fifth line — invokeOnCancellation — is the one that decides whether cancelling actually stops anything, and it's the one people leave out.

Let's Make a Callback Suspend: suspendCancellableCoroutine, Line by Line

Some APIs will never be suspend functions. LocationManager, SensorManager, half the Play Services surface, every SDK written before 2018. They take a callback, they call it later, and that's that.

suspendCancellableCoroutine is the bridge. It's a small function and most of it is obvious — except one line, which is the only one that matters.

The code here is from Philipp Lackner's Coroutines Masterclass, specifically the "converting a callback to a suspend function" commit. Worth reading the original — I'm walking through it because the shape is one you'll reuse constantly.

The function#

Kotlin
suspend fun Context.getLocation(): Location {
    return suspendCancellableCoroutine { continuation ->
        val locationManager = getSystemService<LocationManager>()!!

        val signal = CancellationSignal()

        if (hasFineLocationPermission && hasCoarseLocationPermission) {
            locationManager.getCurrentLocation(
                LocationManager.NETWORK_PROVIDER,
                signal,
                mainExecutor
            ) { location ->
                continuation.resume(location)
            }
        } else {
            continuation.resumeWithException(
                RuntimeException("Missing location permission")
            )
        }

        continuation.invokeOnCancellation {
            signal.cancel()
        }
    }
}

Four moving parts. The suspension, the success path, the failure path, and the cancellation path.

1. The suspension: a continuation is a paused function#

suspendCancellableCoroutine hands you a continuation and then stops. The calling coroutine is suspended — not blocked, suspended. Its thread is free.

The continuation is the "rest of the function", captured as an object. Call resume(value) and execution picks up where it left off, with value as the return. Call resumeWithException(e) and it picks up by throwing.

That's the whole mechanism. A callback fires, you resume, the suspend function returns. The caller never knows a callback was involved.

2. invokeOnCancellation — the line people delete#

Kotlin
continuation.invokeOnCancellation {
    signal.cancel()
}

Remove this and everything still compiles. Everything still appears to work.

Here's what actually changes. Cancel the coroutine — the screen closes, the viewModelScope clears, whatever — and your code stops waiting for the location. Good.

But locationManager.getCurrentLocation doesn't know that. The request is still live. The radio is still on. When it eventually produces a fix, it calls a continuation nobody is waiting for.

That's a work leak, not a memory leak. LeakCanary won't see it. There's no retained Activity, no growing heap. The only symptom is battery, and battery problems get blamed on everything except the thing that caused them.

invokeOnCancellation is the bridge in the other direction: coroutine cancelled → tell the callback API to stop. Without it the bridge is one-way, and cancellation stops half the work.

Every callback API you wrap has some version of this. CancellationSignal here; removeListener for a SensorManager; call.cancel() for OkHttp; unregisterReceiver for a BroadcastReceiver. If the API has no way to stop, that's worth knowing before you wrap it — it means cancellation will always be partial.

3. suspendCancellableCoroutine, not suspendCoroutine#

There are two. The difference is the whole story.

suspendCoroutine gives you a continuation that ignores cancellation entirely. Cancel the coroutine and it keeps sitting there, suspended, until the callback fires. There's no invokeOnCancellation to call, because there's nothing listening.

Worse, a suspendCoroutine that never gets resumed — the callback errored, the API dropped it — suspends forever. Not an exception. Not a timeout. A coroutine that simply never finishes, holding its scope open.

Default to suspendCancellableCoroutine. I've never found a case where the non-cancellable one was the right answer.

4. Exactly once, and why that's a constraint not a detail#

A continuation may be resumed once. Resume it twice and you get IllegalStateException: Already resumed.

That single rule decides what this pattern is for:

One-shot callbacks — yes. "Get me the current location." "Load this file." "Return the result of this query." One request, one answer, done.

Repeating listeners — no. LocationListener that fires every few seconds. A sensor stream. A BroadcastReceiver. Wrapping one of those in suspendCancellableCoroutine works exactly once and then crashes on the second emission.

Those need callbackFlow:

Kotlin
fun Context.locationUpdates(): Flow<Location> = callbackFlow {
    val listener = LocationListener { trySend(it) }
    locationManager.requestLocationUpdates(..., listener)
    awaitClose { locationManager.removeUpdates(listener) }
}

Same idea, same cancellation discipline — awaitClose is invokeOnCancellation wearing a different hat — but it can emit many times.

Getting this choice wrong is the most common way callback bridging fails in production. The suspend version passes every test with a single emission and crashes the first time the real device produces two.

What I'd change#

The permission request races the location request.

Kotlin
ActivityCompat.requestPermissions(this, arrayOf(...), 0)

lifecycleScope.launch {
    val location = getLocation()   // runs immediately
}

requestPermissions is asynchronous — it shows a dialog and returns. The coroutine launches straight away, checks permissions that haven't been granted yet, and takes the else branch. On a fresh install this fails every time.

Fine in a teaching demo where you tap Allow and re-run. In real code the location request belongs in the permission result callback, or behind a rememberLauncherForActivityResult.

@RequiresApi(R) is a real constraint. That getCurrentLocation overload is API 30+. If your minSdk is lower you need a fallback path — requestSingleUpdate, or Play Services' FusedLocationProviderClient, which has its own cancellation token and is what most production apps use anyway.

getSystemService<LocationManager>()!! — the !! is fine on a real device and less fine in a unit test with a mocked context. Small thing, easy to make safe.

Throwing works too. resumeWithException is correct, but you can also just throw inside the block before suspending — it propagates normally. Worth knowing so you're not surprised by either style in someone else's code.

Check yourself#

Check yourself

1You delete the invokeOnCancellation block. What breaks?

2What's the difference between suspendCoroutine and suspendCancellableCoroutine?

3Your callback fires twice. What happens with suspendCancellableCoroutine?

4You need to wrap a LocationListener that emits every few seconds. What do you use?

5Where should the location request go, given requestPermissions is asynchronous?

Answers are client-side only — nothing is recorded.

Wrapping up#

Three ideas, and only the first is about location:

A suspend function is a callback with the plumbing hidden. resume is the callback. The continuation is the rest of your function, waiting.

Cancellation has to travel both ways. Coroutine cancelled means tell the other system to stop. Without invokeOnCancellation you've built a one-way bridge, and the leak it causes is the kind no tool reports.

Exactly-once is a design constraint, not trivia. It's what separates suspendCancellableCoroutine from callbackFlow, and picking wrong gives you code that passes every test and crashes on a real device.

Until then, let's make this work.

Keep reading

More breakdowns

Kotlin & Core Concepts

Let's Make Coroutines Work: What is a Coroutine?

Threads block, and blocked threads freeze apps. Here's what a coroutine actually is, what a suspension point does, and why you can run 100,000 of them on a single thread.

· 2 min read
Android Architecture

Let's Make WorkManager Work: Constraints, Unique Work, and a Retry That Stops

Forty lines that run a job surviving the app being killed. Three decisions inside are worth stealing, and one line is a bug you can ship without noticing — because CancellationException is an Exception.

· 4 min read
Design System

Let's Make an Avatar System Work: Layering, Negative Spacing, and Constrained APIs

Two composables, three decisions. An image drawn on top of initials replaces an if/else. A negative number replaces a custom layout. And an enum stops the call site inventing its own sizes.

· 4 min read