Brifing

Managing Ghost Work in Kotlin Coroutines

Preventing silent failures and state corruption caused by uncooperative coroutine cancellation in production environments.

ProAndroidDev - Medium2 min

1The Cooperative Cancellation Reality

Kotlin coroutine cancellation is inherently cooperative, meaning every suspend function must actively propagate the signal. If a boundary is broken, background work continues even after its owner is destroyed. In production, this leads to false error logs, stale Bluetooth connections, and lost data, as the application state drifts without triggering any crashes.

2Handling Exceptions and Cancellation

Broad catch blocks often swallow CancellationException, treating normal lifecycle events as failures. This pollutes telemetry with false error reports and returns misleading empty results to callers. Developers must explicitly rethrow CancellationException before handling other errors to ensure the coroutine system correctly identifies the cancellation signal rather than misinterpreting it as a query failure.

3Securing Callback Bridges

Standard suspendCoroutine bridges are not inherently cancellable, causing coroutines to hang if an SDK fails to respond. Using suspendCancellableCoroutine allows developers to register cleanup via invokeOnCancellation. This ensures underlying network requests or Rx subscriptions are terminated immediately when the parent scope is cancelled, preventing memory leaks and unnecessary background resource consumption.

4Blocking Code and Threading

Moving blocking calls to Dispatchers.IO does not make them cancellable; the thread remains occupied until the operation completes. This can lead to ANRs or wasted IO capacity. Developers should prefer suspend APIs like await. If blocking code is unavoidable, runInterruptible can connect JVM thread interruption to the coroutine cancellation mechanism effectively.

5Managing Orphaned Coroutines

Launching coroutines in ad-hoc scopes, such as onPacketReceived, creates work that cannot be cancelled upon disconnect. Using a single owner scope managed by a connection manager is essential. Additionally, employing a Mutex ensures that concurrent sends are serialized, preventing race conditions that often manifest as unreliable connections during rapid state changes.

6Timeout and Lifecycle Logic

Using withTimeoutOrNull provides a cleaner way to handle expected delays compared to standard exceptions. For callbackFlows, awaitClose is critical to unregister receivers when collection ends. Without these patterns, discovery processes may stop without clear recovery actions, making expected timeouts appear as connection failures to the end user in production logs.

7Race Conditions During Logout

Cancelling a scope during logout can interrupt critical operations like token saving, leading to authentication state loss. By keeping dependent operations within the same coroutine, developers ensure that logout only proceeds after the save completes. This prevents the common issue where failure symptoms appear in the next session, far from the cause.

8Architecting Reusable Scopes

Cancelling a scope permanently stops all future work within that object. If an object is reusable, developers should use cancelChildren instead of cancelling the entire scope. This keeps the parent job active for subsequent lifecycle events. Proper scope management ensures background subsystems remain healthy without requiring full object recreation after minor state transitions.

Original article: ProAndroidDev - Medium — Managing Ghost Work in Kotlin Coroutines