Most Android platform APIs and third party SDKs were designed around callbacks. The Google Play Billing Library uses PurchasesUpdatedListener. Location Services uses LocationCallback. Bluetooth GATT uses BluetoothGattCallback. Camera2 uses CameraCaptureSession.StateCallback. If you have been writing Android code for more than a few months, you have written deeply nested callback chains that are hard to read, hard to test, and hard to reason about when errors occur at any stage. Kotlin coroutines solve this with suspend functions, but the platform and most SDKs do not hand you suspend functions out of the box. You need a bridge.

In this article, you’ll explore the suspend coroutine bridge pattern in detail, tracing how it converts callback based APIs into clean suspend functions, how to handle different callback shapes from single value results to multi parameter success and error pairs, how to design exception hierarchies that preserve error semantics across the bridge, why you should always reach for suspendCancellableCoroutine rather than the older suspendCoroutine — even when the underlying callback API has no native cancellation — and how production SDKs like RevenueCat apply these patterns at scale across 20+ API surfaces.

The fundamental problem: Callbacks do not compose

Consider a common billing flow on Android. You need to connect to the billing service, query products, then initiate a purchase. With the raw callback API, this looks like:

1billingClient.startConnection(object : BillingClientStateListener {
2    override fun onBillingSetupFinished(result: BillingResult) {
3        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
4            val params = QueryProductDetailsParams.newBuilder()
5                .setProductList(listOf(/* ... */))
6                .build()
7            billingClient.queryProductDetailsAsync(params) { billingResult, productDetails ->
8                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
9                    // Now launch the purchase flow...
10                }
11            }
12        }
13    }
14
15    override fun onBillingServiceDisconnected() {
16        // Retry? Log? Both callbacks share no structured error path.
17    }
18})

Each callback nests inside the previous one. Error handling is scattered across if checks and separate override methods. There is no structured way to propagate failures up the chain. And this is only two callbacks deep. A full billing flow, connecting, querying, purchasing, acknowledging, involves four or five nested levels.

The suspend function equivalent reads like sequential code:

1val connected = billingClient.awaitConnect()
2val products = billingClient.awaitQueryProducts(productIds)
3val result = billingClient.awaitPurchase(activity, products.first())
4billingClient.awaitAcknowledge(result.purchaseToken)

This is not a language feature you get for free. Each of those await functions requires a bridge that converts the underlying callback into a coroutine suspension point. Let’s trace through exactly how that bridge works.

The core bridge: The core bridge: suspendCancellableCoroutine

Kotlin provides two primitives for bridging between callback based code and coroutines: suspendCoroutine and suspendCancellableCoroutine. They look almost identical — both suspend the current coroutine and hand you a continuation that you later resume with a result or resumeWithException with an error — but only one of them is safe to use in real code: suspendCancellableCoroutine.

The plain rule, stated up front: do not use suspendCoroutine in application or library code. Its continuation is not tied to coroutine cancellation, so when a parent Job is cancelled the leaf cannot tear down. The coroutine stays parked, holding references to its scope and context, until (or unless) the callback eventually fires. That is a structured concurrency hole and, in practice, a latent memory and resource leak.

Worse, cancellation and exceptions further up the tree can’t propagate past a coroutine that refuses to finish, which is how a single stuck bridge wedges a whole screen’s scope. suspendCancellableCoroutine closes the hole by giving you a CancellableContinuation that participates in cancellation, even when the underlying callback based API has no cancellation support of its own. Every example in this article uses it.

The simplest bridge handles a single value callback:

1suspend fun BillingClient.awaitConnect(): Boolean {
2    return suspendCancellableCoroutine { continuation ->
3        startConnection(object : BillingClientStateListener {
4            override fun onBillingSetupFinished(result: BillingResult) {
5                continuation.resume(
6                    result.responseCode == BillingClient.BillingResponseCode.OK
7                )
8            }
9
10            override fun onBillingServiceDisconnected() {
11                // Connection lost after setup, not during initial connect
12            }
13        })
14        continuation.invokeOnCancellation { endConnection() }
15    }
16}

The pattern has three parts. First, call suspendCancellableCoroutine to pause the coroutine and receive a CancellableContinuation. Second, call the callback based API, passing an anonymous implementation that captures the continuation. Third, inside the callback, call resume or resumeWithException to deliver the result and unfreeze the coroutine. When the underlying API supports teardown, register a cleanup block through invokeOnCancellation so cancellation actually releases the resource instead of waiting on a callback that may never come back.

One important rule: you must call resume or resumeWithException at most once per non cancelled continuation. Calling it zero times means the coroutine hangs forever (unless cancelled from outside). Calling it twice on a live continuation throws IllegalStateException. After cancellation, CancellableContinuation.resume simply drops the value, so you do not need to guard every resume with an isActive check — but every non-cancelled code path through your callback must reach exactly one resume call.

Success and error callbacks: The two-path bridge

Most SDK APIs split their callbacks into success and error paths. This maps naturally to resume and resumeWithException. Let’s examine how RevenueCat’s Android SDK bridges its offerings API.

Looking at the awaitOfferings extension function:

1@JvmSynthetic
2@Throws(PurchasesException::class)
3suspend fun Purchases.awaitOfferings(): Offerings {
4    return suspendCancellableCoroutine { continuation ->
5        getOfferingsWith(
6            onSuccess = continuation::resume,
7            onError = { continuation.resumeWithException(PurchasesException(it)) },
8        )
9    }
10}

Notice the structure. The onSuccess path uses a method reference continuation::resume directly. When the callback signature matches (T) -> Unit and the continuation expects T, a method reference is the cleanest form. The onError path wraps the raw PurchasesError in a PurchasesException before passing it to resumeWithException. This is necessary because resumeWithException expects a Throwable, but the SDK’s error type is a plain data object, not an exception.

The @JvmSynthetic annotation prevents this extension function from appearing in Java code, since Java callers should use the callback version. The @Throws annotation generates the throws clause in the bytecode so Java interop and documentation tools correctly report what this function can throw.

The callback factory: Abstracting interface boilerplate

Before the suspend bridge can work, there is another layer of bridging. Many Android SDK APIs accept typed callback interfaces, not lambda pairs. For example, Google Play Billing uses PurchaseCallback with onCompleted and onError methods. RevenueCat’s internal API uses ReceiveOfferingsCallback with onReceived and onError.

Writing anonymous implementations of these interfaces inside every suspend function creates noise. The solution is a set of factory functions that convert lambda pairs into typed callback objects.

Looking at the callback factory for offerings:

1internal fun receiveOfferingsCallback(
2    onSuccess: (offerings: Offerings) -> Unit,
3    onError: (error: PurchasesError) -> Unit,
4) = object : ReceiveOfferingsCallback {
5    override fun onReceived(offerings: Offerings) {
6        onSuccess(offerings)
7    }
8
9    override fun onError(error: PurchasesError) {
10        onError(error)
11    }
12}

This is a small function, but it matters at scale. RevenueCat’s SDK has factory functions for offerings, customer info, store products, purchases, login, sync, and more. Each one converts the (onSuccess, onError) lambda pair into the specific callback interface the underlying API expects.

The purchase callback factory handles a more complex shape:

1internal fun purchaseCompletedCallback(
2    onSuccess: (purchase: StoreTransaction, customerInfo: CustomerInfo) -> Unit,
3    onError: (error: PurchasesError, userCancelled: Boolean) -> Unit,
4) = object : PurchaseCallback {
5    override fun onCompleted(storeTransaction: StoreTransaction, customerInfo: CustomerInfo) {
6        onSuccess(storeTransaction, customerInfo)
7    }
8
9    override fun onError(error: PurchasesError, userCancelled: Boolean) {
10        onError(error, userCancelled)
11    }
12}

Notice the asymmetry. The success callback delivers two values: the transaction and the updated customer info. The error callback also delivers two values: the error and a boolean indicating whether the user cancelled. This is not a simple (T) -> Unit shape. Bridging this to a suspend function requires additional design decisions.

Multi-value callbacks: Wrapper classes

When a callback delivers multiple values, you need a container to return them from a single suspend function. The approach is straightforward: define a data class that bundles the values.

Looking at RevenueCat’s PurchaseResult:

1@Poko
2class PurchaseResult(
3    val storeTransaction: StoreTransaction,
4    val customerInfo: CustomerInfo,
5)

The suspend bridge then constructs this wrapper in the success path:

1@JvmSynthetic
2@Throws(PurchasesTransactionException::class)
3suspend fun Purchases.awaitPurchase(purchaseParams: PurchaseParams): PurchaseResult {
4    return suspendCancellableCoroutine { continuation ->
5        purchase(
6            purchaseParams = purchaseParams,
7            callback = purchaseCompletedCallback(
8                onSuccess = { storeTransaction, customerInfo ->
9                    continuation.resume(PurchaseResult(storeTransaction, customerInfo))
10                },
11                onError = { purchasesError, userCancelled ->
12                    continuation.resumeWithException(
13                        PurchasesTransactionException(purchasesError, userCancelled)
14                    )
15                },
16            ),
17        )
18    }
19}

Two things are worth noting here. First, the success path wraps both values into a PurchaseResult so the caller gets a single typed return value. Second, the error path uses PurchasesTransactionException instead of the regular PurchasesException. This is because the error callback carries an extra userCancelled boolean that callers need to distinguish user initiated cancellation from actual errors. The exception hierarchy preserves this information.

Exception hierarchies: Preserving error semantics

A common mistake when bridging callbacks to coroutines is losing error information. Wrapping every error in a generic Exception(message) throws away the structured error code that callers need for programmatic error handling.

Looking at RevenueCat’s exception design:

1open class PurchasesException internal constructor(
2    val error: PurchasesError,
3    internal val overridenMessage: String? = null,
4) : Exception() {
5
6    val code: PurchasesErrorCode
7        get() = error.code
8
9    val underlyingErrorMessage: String?
10        get() = error.underlyingErrorMessage
11
12    override val message: String
13        get() = overridenMessage ?: error.message
14}

The exception wraps the original PurchasesError object, preserving the typed PurchasesErrorCode enum. Callers can use a when expression on the code to handle specific error conditions:

1try {
2    val offerings = Purchases.sharedInstance.awaitOfferings()
3    showPaywall(offerings)
4} catch (e: PurchasesException) {
5    when (e.code) {
6        PurchasesErrorCode.NetworkError -> showRetryDialog()
7        PurchasesErrorCode.StoreProblemError -> showStoreErrorMessage()
8        else -> showGenericError(e.message)
9    }
10}

The transaction exception extends this with the cancellation flag:

1class PurchasesTransactionException(
2    purchasesError: PurchasesError,
3    val userCancelled: Boolean,
4) : PurchasesException(purchasesError)

This hierarchy means callers can catch PurchasesException for all errors, or catch PurchasesTransactionException specifically for purchase errors where they need to check whether the user cancelled. The is check works naturally:

1try {
2    val result = Purchases.sharedInstance.awaitPurchase(params)
3    grantEntitlement(result.customerInfo)
4} catch (e: PurchasesTransactionException) {
5    if (e.userCancelled) {
6        // User tapped back or dismissed the sheet. Not an error.
7        return
8    }
9    showPurchaseError(e.message)
10} catch (e: PurchasesException) {
11    showGenericError(e.message)
12}

The key observation: the exception hierarchy mirrors the callback signature shapes. A callback with (error) maps to PurchasesException. A callback with (error, userCancelled) maps to PurchasesTransactionException. This is not accidental. It is a deliberate design that makes the suspend API as expressive as the callback API.

The Result<T> variant: Exceptions are not always what you want

Not every caller wants to use try/catch. Some prefer kotlin.Result<T> for composable error handling. RevenueCat provides a second variant for every suspend bridge:

1@JvmSynthetic
2suspend fun Purchases.awaitOfferingsResult(): Result<Offerings> =
3    suspendCancellableCoroutine { continuation ->
4        getOfferingsWith(
5            onSuccess = { continuation.resume(Result.success(it)) },
6            onError = { continuation.resume(Result.failure(PurchasesException(it))) },
7        )
8    }

The key difference: the error path calls continuation.resume(Result.failure(...)) instead of continuation.resumeWithException(...). From the coroutine’s perspective, the function always completes successfully. It returns a Result that the caller unwraps:

1val result = Purchases.sharedInstance.awaitOfferingsResult()
2result.fold(
3    onSuccess = { offerings -> showPaywall(offerings) },
4    onFailure = { error -> showError(error.message) },
5)

This pattern is useful in pipelines where you want to chain operations without try/catch blocks:

1suspend fun loadPaywallData(): Result<PaywallData> {
2    return Purchases.sharedInstance.awaitOfferingsResult()
3        .mapCatching { offerings ->
4            val currentOffering = offerings.current
5                ?: throw IllegalStateException("No current offering")
6            PaywallData(currentOffering)
7        }
8}

The purchase Result variant follows the same pattern:

1suspend fun Purchases.awaitPurchaseResult(
2    purchaseParams: PurchaseParams
3): Result<PurchaseResult> {
4    return suspendCancellableCoroutine { continuation ->
5        purchase(
6            purchaseParams = purchaseParams,
7            callback = purchaseCompletedCallback(
8                onSuccess = { storeTransaction, customerInfo ->
9                    continuation.resume(
10                        Result.success(PurchaseResult(storeTransaction, customerInfo))
11                    )
12                },
13                onError = { purchasesError, userCancelled ->
14                    continuation.resume(
15                        Result.failure(
16                            PurchasesTransactionException(purchasesError, userCancelled)
17                        )
18                    )
19                },
20            ),
21        )
22    }
23}

The error information is not lost. The PurchasesTransactionException is still inside the Result.failure, so callers who need the userCancelled flag can check it:

1val result = Purchases.sharedInstance.awaitPurchaseResult(params)
2result.onFailure { error ->
3    if (error is PurchasesTransactionException && error.userCancelled) {
4        return
5    }
6    showError(error.message)
7}

This dual API approach, throwing suspend functions and Result returning suspend functions, gives consumers the choice without forcing one style. The SDK does not pick winners. It supports both.

The lambda convenience layer: Bridging before the bridge

There is a middle layer between the raw callback interface API and the suspend bridge that is worth examining. RevenueCat provides extension functions that accept lambda pairs instead of typed callback objects:

1fun Purchases.getOfferingsWith(
2    onError: (error: PurchasesError) -> Unit = ON_ERROR_STUB,
3    onSuccess: (offerings: Offerings) -> Unit,
4) {
5    getOfferings(receiveOfferingsCallback(onSuccess, onError))
6}

This is a two step bridge design. The lambda extension (getOfferingsWith) converts lambdas to a typed callback. The suspend extension (awaitOfferings) converts the lambda extension to a coroutine. Each layer does one thing.

Notice the default error handler:

internal val ON_ERROR_STUB: (error: PurchasesError) -> Unit = {}

This allows callers who do not care about errors to omit the error handler. This is useful for fire and forget operations, but should be used carefully since silently swallowing errors is a common source of bugs.

The purchase version has its own stub:

internal val ON_PURCHASE_ERROR_STUB: (error: PurchasesError, userCancelled: Boolean) -> Unit =
    { _, _ -> }

Two separate stubs for two different callback shapes. Each matches the exact lambda signature its callback requires.

Real-world application: Bridging Google Play Billing directly

The same suspendCancellableCoroutine pattern applies to any callback based Android API. Here is how you would bridge Google Play Billing’s acknowledgment API:

1suspend fun BillingClient.awaitAcknowledge(purchaseToken: String): Boolean {
2    return suspendCancellableCoroutine { continuation ->
3        val params = AcknowledgePurchaseParams.newBuilder()
4            .setPurchaseToken(purchaseToken)
5            .build()
6        acknowledgePurchase(params) { billingResult ->
7            continuation.resume(
8                billingResult.responseCode == BillingClient.BillingResponseCode.OK
9            )
10        }
11    }
12}

And the consume API follows the same structure:

1suspend fun BillingClient.awaitConsume(purchaseToken: String): Boolean {
2    return suspendCancellableCoroutine { continuation ->
3        val params = ConsumeParams.newBuilder()
4            .setPurchaseToken(purchaseToken)
5            .build()
6        consumeAsync(params) { billingResult, _ ->
7            continuation.resume(
8                billingResult.responseCode == BillingClient.BillingResponseCode.OK
9            )
10        }
11    }
12}

Even the billing client connection, which has two separate callback methods (onBillingSetupFinished and onBillingServiceDisconnected), bridges cleanly:

1suspend fun BillingClient.awaitConnect(): Boolean {
2    if (isReady) return true
3    return suspendCancellableCoroutine { continuation ->
4        startConnection(object : BillingClientStateListener {
5            override fun onBillingSetupFinished(billingResult: BillingResult) {
6                continuation.resume(
7                    billingResult.responseCode == BillingClient.BillingResponseCode.OK
8                )
9            }
10
11            override fun onBillingServiceDisconnected() {
12                // Called when connection is lost after setup, not during setup.
13                // The continuation has already been resumed by onBillingSetupFinished.
14            }
15        })
16        continuation.invokeOnCancellation { endConnection() }
17    }
18}

The onBillingServiceDisconnected method is called after a successful setup when the connection is later lost, not as an alternative to onBillingSetupFinished during the initial connection. This is an important subtlety. If both methods could fire during setup, you would need additional state tracking to ensure exactly one resume call.

Why suspendCancellableCoroutine is always the right default

The earlier rule — “do not use suspendCoroutine in application or library code” — deserves its own section, because it’s easy to look at the two function names and assume they are equally valid tools for different jobs. They are not. suspendCoroutine is a low level primitive whose semantics predate the structured concurrency model we actually build on today, and its behavior is fundamentally incompatible with cancellation.

Here is what actually happens when you call suspendCoroutine inside a cancellable scope. The continuation you receive is a plain Continuation<T>. It has no knowledge of the Job that invoked it. When the parent Job is cancelled — because a ViewModel is cleared, a LaunchedEffect leaves composition, a coroutineScope { } block throws, or the user navigates away — the cancellation signal travels to every child that participates in structured concurrency. Your suspendCoroutine leaf does not participate. It cannot be cancelled, so Job.cancelAndJoin() cannot complete, sibling failures cannot propagate past it, and the entire scope is held open waiting for a callback that may never fire.

The practical consequences are the ones you would expect from a leak:

  • The continuation retains the calling coroutine’s context, including references to lifecycle-scoped objects like ViewModels, Activitys, or Composers. Those objects can no longer be garbage collected.
  • Any resource registered in the surrounding scope — a database session, a Bluetooth GATT handle, an open socket — stays alive until the callback eventually comes back.
  • Exceptions in sibling coroutines cannot be delivered in a timely way; the scope can’t finish cancelling its other children while the leaf is stuck, so failures silently disappear or surface seconds (or hours) later, detached from their cause.

suspendCancellableCoroutine is the fix, and it is the fix even when the underlying callback based API has no native cancellation to hook into. Its continuation participates in the coroutine’s Job graph: cancellation of the parent immediately resumes the continuation with a CancellationException, the leaf tears down, the scope unwinds, and references are released. The underlying callback can still fire later — you just ignore it, because CancellableContinuation.resume on an already-cancelled continuation is a safe no-op. The in-flight network request or SDK call keeps running to completion on its own, but it no longer holds your coroutine hostage.

The upgrade from suspendCoroutine to suspendCancellableCoroutine is almost always free. For callbacks you have no control over, the change is a single-word rename:

1// Before: structured concurrency hole
2suspend fun Purchases.awaitOfferings(): Offerings = suspendCoroutine { continuation ->
3    getOfferingsWith(
4        onSuccess = continuation::resume,
5        onError = { continuation.resumeWithException(PurchasesException(it)) },
6    )
7}
8
9// After: cancellation-safe
10suspend fun Purchases.awaitOfferings(): Offerings = suspendCancellableCoroutine { continuation ->
11    getOfferingsWith(
12        onSuccess = continuation::resume,
13        onError = { continuation.resumeWithException(PurchasesException(it)) },
14    )
15}

For callbacks whose underlying operation can be cancelled or whose resources should be released eagerly, add invokeOnCancellation for cleanup:

1suspend fun BillingClient.awaitConnect(): Boolean {
2    if (isReady) return true
3    return suspendCancellableCoroutine { continuation ->
4        startConnection(object : BillingClientStateListener {
5            override fun onBillingSetupFinished(billingResult: BillingResult) {
6                continuation.resume(
7                    billingResult.responseCode == BillingClient.BillingResponseCode.OK
8                )
9            }
10
11            override fun onBillingServiceDisconnected() {}
12        })
13        continuation.invokeOnCancellation { endConnection() }
14    }
15}

That’s it. You get correctness at no complexity cost. There is no category of “short lived, non cancellable” callback where suspendCoroutine is the pragmatic choice — short lived operations still need to honor cancellation, because the thing that is actually short lived is only short lived on average. Network calls stall. App processes freeze mid request. An SDK callback that “always fires quickly” has a long tail of cases where it doesn’t, and those are exactly the cases where a leaked scope matters most.

Treat suspendCoroutine as a platform primitive that exists mainly so that suspendCancellableCoroutine can be built on top of it. In the code you write, always reach for suspendCancellableCoroutine.

Applying the pattern beyond billing

The bridge pattern is not limited to billing APIs. The same approach works for any callback based Android API. A few examples:

For FusedLocationProviderClient, which delivers location through LocationCallback:

1suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? {
2    return suspendCancellableCoroutine { continuation ->
3        lastLocation
4            .addOnSuccessListener { location -> continuation.resume(location) }
5            .addOnFailureListener { e -> continuation.resumeWithException(e) }
6    }
7}

The Google Tasks API doesn’t expose a cancellation hook on lastLocation, but the bridge still uses suspendCancellableCoroutine: when the caller’s scope is cancelled, the continuation tears down immediately and the in-flight Task is left to complete on its own — its eventual callback simply finds an already-cancelled continuation and is a no-op.

For SharedPreferences, which uses OnSharedPreferenceChangeListener for change notifications:

1suspend fun SharedPreferences.awaitChange(key: String): String? {
2    return suspendCancellableCoroutine { continuation ->
3        val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, changedKey ->
4            if (changedKey == key) {
5                continuation.resume(prefs.getString(key, null))
6            }
7        }
8        registerOnSharedPreferenceChangeListener(listener)
9        continuation.invokeOnCancellation {
10            unregisterOnSharedPreferenceChangeListener(listener)
11        }
12    }
13}

The common structure is always the same: wrap with suspendCancellableCoroutine, register the callback, resume when the callback fires. What changes is the callback shape, how many values you need to capture, and whether cancellation cleanup is needed.

Common mistakes to avoid

Resuming zero times

If there is a code path where the callback never fires, the coroutine suspends forever. This is especially common with connection listeners that have multiple callback methods:

1// Dangerous: if onBillingServiceDisconnected fires before onBillingSetupFinished,
2// the coroutine never resumes
3suspend fun BillingClient.awaitConnectBroken(): Boolean {
4    return suspendCancellableCoroutine { continuation ->
5        startConnection(object : BillingClientStateListener {
6            override fun onBillingSetupFinished(billingResult: BillingResult) {
7                continuation.resume(true)
8            }
9
10            override fun onBillingServiceDisconnected() {
11                // Bug: in rare edge cases this might fire first,
12                // and onBillingSetupFinished never fires
13            }
14        })
15    }
16}

Using suspendCancellableCoroutine here means a parent-scope cancellation can still tear the coroutine down, but if no cancellation arrives the bridge is still wedged forever waiting on a callback that never came. The right primitive doesn’t save you from a missing resume path.

The fix is to ensure every callback path reaches a resume call, or use suspendCancellableCoroutine with a timeout:

1suspend fun BillingClient.awaitConnectSafe(): Boolean {
2    return withTimeout(5_000) {
3        suspendCancellableCoroutine { continuation ->
4            startConnection(object : BillingClientStateListener {
5                override fun onBillingSetupFinished(billingResult: BillingResult) {
6                    if (continuation.isActive) {
7                        continuation.resume(
8                            billingResult.responseCode == BillingClient.BillingResponseCode.OK
9                        )
10                    }
11                }
12
13                override fun onBillingServiceDisconnected() {
14                    if (continuation.isActive) {
15                        continuation.resume(false)
16                    }
17                }
18            })
19        }
20    }
21}

Resuming twice

If a callback can fire multiple times (like a location listener that delivers updates continuously), the suspend bridge is the wrong tool entirely. Each call to resume after the first throws IllegalStateException, regardless of whether you used suspendCoroutine or suspendCancellableCoroutine. For repeating callbacks, use callbackFlow instead:

1fun FusedLocationProviderClient.locationUpdates(
2    request: LocationRequest
3): Flow<Location> = callbackFlow {
4    val callback = object : LocationCallback() {
5        override fun onLocationResult(result: LocationResult) {
6            result.lastLocation?.let { trySend(it) }
7        }
8    }
9    requestLocationUpdates(request, callback, Looper.getMainLooper())
10    awaitClose { removeLocationUpdates(callback) }
11}
12

suspendCancellableCoroutine is for one shot callbacks. callbackFlow is for streaming callbacks. Choosing the wrong primitive leads to crashes or hangs.

Losing error type information

Wrapping all errors as Exception(error.message) strips away the structured error data callers need:

1// Bad: caller cannot programmatically distinguish error types
2onError = { error ->
3    continuation.resumeWithException(Exception(error.message))
4}
5
6// Good: caller can match on error code
7onError = { error ->
8    continuation.resumeWithException(PurchasesException(error))
9}

The extra work of defining a typed exception class pays for itself every time a caller needs to handle specific error conditions differently.

Conclusion

In this article, you’ve explored the suspendCancellableCoroutine bridge pattern from its simplest form, a single value callback, through the production patterns used in RevenueCat’s Android SDK: multi value wrapper classes, typed exception hierarchies, callback factory functions, and dual API styles with both throwing and Result returning variants. The pattern is always the same three steps: suspend the coroutine, register the callback, and resume exactly once. And it always uses suspendCancellableCoroutine, never the older suspendCoroutine, so every leaf in your coroutine tree honors structured concurrency.

Understanding this bridge is practical knowledge for any Android developer. Most of the platform APIs you use daily, billing, location, Bluetooth, camera, were designed around callbacks. Converting them to suspend functions makes your code sequential, testable, and composable. The patterns covered here, especially the callback factory layer, the typed exception hierarchy, and the Result<T> variant, are directly reusable in your own projects.

Whether you’re bridging Google Play Billing’s PurchasesUpdatedListener, wrapping a legacy networking library, or building a suspend friendly API for your own SDK, these patterns provide the foundation for clean, correct coroutine integration on Android.

As always, happy coding!

Jaewoong (skydoves)