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#
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#
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:
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.
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?
That invisibility is the danger. No crash, no leak warning, no failing test. The radio stays on and the battery cost gets blamed on something else.
2What's the difference between suspendCoroutine and suspendCancellableCoroutine?
And a suspendCoroutine that's never resumed suspends forever. Not an error, not a timeout — just a coroutine that never completes.
3Your callback fires twice. What happens with suspendCancellableCoroutine?
A continuation is single-use. This is why the pattern is only for one-shot callbacks.
4You need to wrap a LocationListener that emits every few seconds. What do you use?
awaitClose plays the same role as invokeOnCancellation — it's how the flow tells the callback API to stop when collection ends.
5Where should the location request go, given requestPermissions is asynchronous?
The sample launches both at once, so on a fresh install the permission check runs before the user has answered the dialog and always takes the else branch.
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.

