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 suspendCoroutine 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, 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, and 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 require a bridge that converts the underlying callback into a coroutine suspension point. Let’s trace through exactly how that bridge works.

The core bridge: suspendCoroutine

Kotlin provides suspendCoroutine as the primitive for bridging between callback-based code and coroutines. The function suspends the current coroutine and gives you a Continuation<T> object. You call continuation.resume(value) to deliver a result, or continuation.resumeWithException(exception) to deliver an error. The coroutine resumes at exactly the point it was suspended.

The simplest bridge handles a single value callback:

1suspend fun BillingClient.awaitConnect(): Boolean {
2    return suspendCoroutine { 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    }
15}

The pattern has three parts. First, call suspendCoroutine to pause the coroutine and receive a Continuation. 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.

One important rule: you must call resume or resumeWithException exactly once. Calling it zero times means the coroutine hangs forever. Calling it twice throws IllegalStateException. Every 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 suspendCoroutine { 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 suspendCoroutine { 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    suspendCoroutine { 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 suspendCoroutine { 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 suspendCoroutine 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 suspendCoroutine { 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 suspendCoroutine { 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 suspendCoroutine { 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    }
17}

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.

suspendCoroutine versus suspendCancellableCoroutine

There is a more advanced variant: suspendCancellableCoroutine. The difference is that it gives you a CancellableContinuation that responds to coroutine cancellation.

1suspend fun BillingClient.awaitConnectCancellable(): Boolean {
2    if (isReady) return true
3    return suspendCancellableCoroutine { continuation ->
4        val listener = 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        }
15
16        startConnection(listener)
17
18        continuation.invokeOnCancellation {
19            // Clean up: end the billing connection if the coroutine is cancelled
20            endConnection()
21        }
22    }
23}

When should you use which? Use suspendCoroutine when the underlying operation cannot be cancelled, or when cancellation cleanup is unnecessary. Most SDK callbacks fall into this category: once you call getOfferings, you cannot un-call it, and letting the callback complete harmlessly is fine.

Use suspendCancellableCoroutine when the underlying operation holds resources that should be released on cancellation. Long running connections, streams, or operations that allocate expensive resources benefit from cancellation support.

RevenueCat’s public coroutine extensions use suspendCoroutine because the underlying SDK calls are short lived network requests. The callback will fire quickly, and the cost of letting it complete (even if the coroutine is cancelled) is negligible compared to the complexity of cancellation handling.

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 suspendCoroutine { continuation ->
3        lastLocation
4            .addOnSuccessListener { location -> continuation.resume(location) }
5            .addOnFailureListener { e -> continuation.resumeWithException(e) }
6    }
7}

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 suspendCoroutine, 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 suspendCoroutine { 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}

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), suspendCoroutine is the wrong tool. Each call to resume after the first throws IllegalStateException. 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}

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

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.