---
title: "Kotlin Coroutine bridges: converting any callback-based Android API to suspend functions"
description: "This article explores the suspendCoroutine bridge pattern, showing how to convert callback APIs into clean suspend functions, handle diverse callback shapes, design proper error propagation, and how SDKs like RevenueCat apply it at scale."
language: "en"
publishedAt: "2026-03-31T23:26:22Z"
updatedAt: "2026-03-31T23:26:22Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
readingTime: 14
canonical: "https://www.revenuecat.com/blog/engineering/kotlin-coroutine-bridge"
---

# Kotlin Coroutine bridges: converting any callback-based Android API to suspend functions

This article explores the suspendCoroutine bridge pattern, showing how to convert callback APIs into clean suspend functions, handle diverse callback shapes, design proper error propagation, and how SDKs like RevenueCat apply it at scale.

## Table of contents

- [The fundamental problem: Callbacks do not compose](#the-fundamental-problem-callbacks-do-not-compose)
- [The core bridge: The core bridge: suspendCancellableCoroutine](#the-core-bridge-the-core-bridge-suspendcancellablecoroutine)
- [Success and error callbacks: The two-path bridge](#success-and-error-callbacks-the-two-path-bridge)
- [The callback factory: Abstracting interface boilerplate](#the-callback-factory-abstracting-interface-boilerplate)
- [Multi-value callbacks: Wrapper classes](#multi-value-callbacks-wrapper-classes)
- [Exception hierarchies: Preserving error semantics](#exception-hierarchies-preserving-error-semantics)
- [The Result<T> variant: Exceptions are not always what you want](#the-resultt-variant-exceptions-are-not-always-what-you-want)
- [The lambda convenience layer: Bridging before the bridge](#the-lambda-convenience-layer-bridging-before-the-bridge)
- [Real-world application: Bridging Google Play Billing directly](#real-world-application-bridging-google-play-billing-directly)
- [Why suspendCancellableCoroutine is always the right default](#why-suspendcancellablecoroutine-is-always-the-right-default)
- [Applying the pattern beyond billing](#applying-the-pattern-beyond-billing)
- [Common mistakes to avoid](#common-mistakes-to-avoid)
  - [Resuming zero times](#resuming-zero-times)
  - [Resuming twice](#resuming-twice)
  - [Losing error type information](#losing-error-type-information)
- [Conclusion](#conclusion)

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:

```kotlin
billingClient.startConnection(object : BillingClientStateListener {
    override fun onBillingSetupFinished(result: BillingResult) {
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            val params = QueryProductDetailsParams.newBuilder()
                .setProductList(listOf(\/* ... *\/))
                .build()
            billingClient.queryProductDetailsAsync(params) { billingResult, productDetails ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    \/\/ Now launch the purchase flow...
                }
            }
        }
    }

    override fun onBillingServiceDisconnected() {
        \/\/ Retry? Log? Both callbacks share no structured error path.
    }
})
```

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:

```kotlin
val connected = billingClient.awaitConnect()
val products = billingClient.awaitQueryProducts(productIds)
val result = billingClient.awaitPurchase(activity, products.first())
billingClient.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:

```kotlin
suspend fun BillingClient.awaitConnect(): Boolean {
    return suspendCancellableCoroutine { continuation ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(result: BillingResult) {
                continuation.resume(
                    result.responseCode == BillingClient.BillingResponseCode.OK
                )
            }

            override fun onBillingServiceDisconnected() {
                \/\/ Connection lost after setup, not during initial connect
            }
        })
        continuation.invokeOnCancellation { endConnection() }
    }
}
```

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:

```kotlin
@JvmSynthetic
@Throws(PurchasesException::class)
suspend fun Purchases.awaitOfferings(): Offerings {
    return suspendCancellableCoroutine { continuation ->
        getOfferingsWith(
            onSuccess = continuation::resume,
            onError = { continuation.resumeWithException(PurchasesException(it)) },
        )
    }
}
```

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:

```kotlin
internal fun receiveOfferingsCallback(
    onSuccess: (offerings: Offerings) -> Unit,
    onError: (error: PurchasesError) -> Unit,
) = object : ReceiveOfferingsCallback {
    override fun onReceived(offerings: Offerings) {
        onSuccess(offerings)
    }

    override fun onError(error: PurchasesError) {
        onError(error)
    }
}
```

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:

```kotlin
internal fun purchaseCompletedCallback(
    onSuccess: (purchase: StoreTransaction, customerInfo: CustomerInfo) -> Unit,
    onError: (error: PurchasesError, userCancelled: Boolean) -> Unit,
) = object : PurchaseCallback {
    override fun onCompleted(storeTransaction: StoreTransaction, customerInfo: CustomerInfo) {
        onSuccess(storeTransaction, customerInfo)
    }

    override fun onError(error: PurchasesError, userCancelled: Boolean) {
        onError(error, userCancelled)
    }
}
```

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`:

```kotlin
@Poko
class PurchaseResult(
    val storeTransaction: StoreTransaction,
    val customerInfo: CustomerInfo,
)
```

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

```kotlin
@JvmSynthetic
@Throws(PurchasesTransactionException::class)
suspend fun Purchases.awaitPurchase(purchaseParams: PurchaseParams): PurchaseResult {
    return suspendCancellableCoroutine { continuation ->
        purchase(
            purchaseParams = purchaseParams,
            callback = purchaseCompletedCallback(
                onSuccess = { storeTransaction, customerInfo ->
                    continuation.resume(PurchaseResult(storeTransaction, customerInfo))
                },
                onError = { purchasesError, userCancelled ->
                    continuation.resumeWithException(
                        PurchasesTransactionException(purchasesError, userCancelled)
                    )
                },
            ),
        )
    }
}
```

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:

```kotlin
open class PurchasesException internal constructor(
    val error: PurchasesError,
    internal val overridenMessage: String? = null,
) : Exception() {

    val code: PurchasesErrorCode
        get() = error.code

    val underlyingErrorMessage: String?
        get() = error.underlyingErrorMessage

    override val message: String
        get() = overridenMessage ?: error.message
}
```

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:

```kotlin
try {
    val offerings = Purchases.sharedInstance.awaitOfferings()
    showPaywall(offerings)
} catch (e: PurchasesException) {
    when (e.code) {
        PurchasesErrorCode.NetworkError -> showRetryDialog()
        PurchasesErrorCode.StoreProblemError -> showStoreErrorMessage()
        else -> showGenericError(e.message)
    }
}
```

The transaction exception extends this with the cancellation flag:

```kotlin
class PurchasesTransactionException(
    purchasesError: PurchasesError,
    val userCancelled: Boolean,
) : 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:

```kotlin
try {
    val result = Purchases.sharedInstance.awaitPurchase(params)
    grantEntitlement(result.customerInfo)
} catch (e: PurchasesTransactionException) {
    if (e.userCancelled) {
        \/\/ User tapped back or dismissed the sheet. Not an error.
        return
    }
    showPurchaseError(e.message)
} catch (e: PurchasesException) {
    showGenericError(e.message)
}
```

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:

```kotlin
@JvmSynthetic
suspend fun Purchases.awaitOfferingsResult(): Result<Offerings> =
    suspendCancellableCoroutine { continuation ->
        getOfferingsWith(
            onSuccess = { continuation.resume(Result.success(it)) },
            onError = { continuation.resume(Result.failure(PurchasesException(it))) },
        )
    }
```

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:

```kotlin
val result = Purchases.sharedInstance.awaitOfferingsResult()
result.fold(
    onSuccess = { offerings -> showPaywall(offerings) },
    onFailure = { error -> showError(error.message) },
)
```

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

```kotlin
suspend fun loadPaywallData(): Result<PaywallData> {
    return Purchases.sharedInstance.awaitOfferingsResult()
        .mapCatching { offerings ->
            val currentOffering = offerings.current
                ?: throw IllegalStateException("No current offering")
            PaywallData(currentOffering)
        }
}
```

The purchase `Result` variant follows the same pattern:

```kotlin
suspend fun Purchases.awaitPurchaseResult(
    purchaseParams: PurchaseParams
): Result<PurchaseResult> {
    return suspendCancellableCoroutine { continuation ->
        purchase(
            purchaseParams = purchaseParams,
            callback = purchaseCompletedCallback(
                onSuccess = { storeTransaction, customerInfo ->
                    continuation.resume(
                        Result.success(PurchaseResult(storeTransaction, customerInfo))
                    )
                },
                onError = { purchasesError, userCancelled ->
                    continuation.resume(
                        Result.failure(
                            PurchasesTransactionException(purchasesError, userCancelled)
                        )
                    )
                },
            ),
        )
    }
}
```

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

```kotlin
val result = Purchases.sharedInstance.awaitPurchaseResult(params)
result.onFailure { error ->
    if (error is PurchasesTransactionException && error.userCancelled) {
        return
    }
    showError(error.message)
}
```

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:

```kotlin
fun Purchases.getOfferingsWith(
    onError: (error: PurchasesError) -> Unit = ON_ERROR_STUB,
    onSuccess: (offerings: Offerings) -> Unit,
) {
    getOfferings(receiveOfferingsCallback(onSuccess, onError))
}
```

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:

```text
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:

```text
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:

```kotlin
suspend fun BillingClient.awaitAcknowledge(purchaseToken: String): Boolean {
    return suspendCancellableCoroutine { continuation ->
        val params = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(purchaseToken)
            .build()
        acknowledgePurchase(params) { billingResult ->
            continuation.resume(
                billingResult.responseCode == BillingClient.BillingResponseCode.OK
            )
        }
    }
}
```

And the consume API follows the same structure:

```kotlin
suspend fun BillingClient.awaitConsume(purchaseToken: String): Boolean {
    return suspendCancellableCoroutine { continuation ->
        val params = ConsumeParams.newBuilder()
            .setPurchaseToken(purchaseToken)
            .build()
        consumeAsync(params) { billingResult, _ ->
            continuation.resume(
                billingResult.responseCode == BillingClient.BillingResponseCode.OK
            )
        }
    }
}
```

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

```kotlin
suspend fun BillingClient.awaitConnect(): Boolean {
    if (isReady) return true
    return suspendCancellableCoroutine { continuation ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                continuation.resume(
                    billingResult.responseCode == BillingClient.BillingResponseCode.OK
                )
            }

            override fun onBillingServiceDisconnected() {
                \/\/ Called when connection is lost after setup, not during setup.
                \/\/ The continuation has already been resumed by onBillingSetupFinished.
            }
        })
        continuation.invokeOnCancellation { endConnection() }
    }
}
```

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 `ViewModel`s, `Activity`s, or `Composer`s. 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:

```kotlin
\/\/ Before: structured concurrency hole
suspend fun Purchases.awaitOfferings(): Offerings = suspendCoroutine { continuation ->
    getOfferingsWith(
        onSuccess = continuation::resume,
        onError = { continuation.resumeWithException(PurchasesException(it)) },
    )
}

\/\/ After: cancellation-safe
suspend fun Purchases.awaitOfferings(): Offerings = suspendCancellableCoroutine { continuation ->
    getOfferingsWith(
        onSuccess = continuation::resume,
        onError = { continuation.resumeWithException(PurchasesException(it)) },
    )
}
```

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

```kotlin
suspend fun BillingClient.awaitConnect(): Boolean {
    if (isReady) return true
    return suspendCancellableCoroutine { continuation ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                continuation.resume(
                    billingResult.responseCode == BillingClient.BillingResponseCode.OK
                )
            }

            override fun onBillingServiceDisconnected() {}
        })
        continuation.invokeOnCancellation { endConnection() }
    }
}
```

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`:

```kotlin
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? {
    return suspendCancellableCoroutine { continuation ->
        lastLocation
            .addOnSuccessListener { location -> continuation.resume(location) }
            .addOnFailureListener { e -> continuation.resumeWithException(e) }
    }
}
```

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:

```kotlin
suspend fun SharedPreferences.awaitChange(key: String): String? {
    return suspendCancellableCoroutine { continuation ->
        val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, changedKey ->
            if (changedKey == key) {
                continuation.resume(prefs.getString(key, null))
            }
        }
        registerOnSharedPreferenceChangeListener(listener)
        continuation.invokeOnCancellation {
            unregisterOnSharedPreferenceChangeListener(listener)
        }
    }
}
```

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:

```kotlin
\/\/ Dangerous: if onBillingServiceDisconnected fires before onBillingSetupFinished,
\/\/ the coroutine never resumes
suspend fun BillingClient.awaitConnectBroken(): Boolean {
    return suspendCancellableCoroutine { continuation ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                continuation.resume(true)
            }

            override fun onBillingServiceDisconnected() {
                \/\/ Bug: in rare edge cases this might fire first,
                \/\/ and onBillingSetupFinished never fires
            }
        })
    }
}
```

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:

```kotlin
suspend fun BillingClient.awaitConnectSafe(): Boolean {
    return withTimeout(5_000) {
        suspendCancellableCoroutine { continuation ->
            startConnection(object : BillingClientStateListener {
                override fun onBillingSetupFinished(billingResult: BillingResult) {
                    if (continuation.isActive) {
                        continuation.resume(
                            billingResult.responseCode == BillingClient.BillingResponseCode.OK
                        )
                    }
                }

                override fun onBillingServiceDisconnected() {
                    if (continuation.isActive) {
                        continuation.resume(false)
                    }
                }
            })
        }
    }
}
```

### **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:

```kotlin
fun FusedLocationProviderClient.locationUpdates(
    request: LocationRequest
): Flow<Location> = callbackFlow {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
            result.lastLocation?.let { trySend(it) }
        }
    }
    requestLocationUpdates(request, callback, Looper.getMainLooper())
    awaitClose { removeLocationUpdates(callback) }
}

```

`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:

```kotlin
\/\/ Bad: caller cannot programmatically distinguish error types
onError = { error ->
    continuation.resumeWithException(Exception(error.message))
}

\/\/ Good: caller can match on error code
onError = { error ->
    continuation.resumeWithException(PurchasesException(error))
}
```

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)](https://github.com/skydoves/)

---

## Related posts

- [Understanding the internal of Flow, StateFlow, and SharedFlow](https://www.revenuecat.com/blog/engineering/flow-internals)
- [Handling edge cases in Google Play Billing](https://www.revenuecat.com/blog/engineering/google-play-edge-cases)
- [Android SDK lifecycle management with Hilt dependency injection](https://www.revenuecat.com/blog/engineering/hilt-sdk-lifecycle)
