plugins/android-skills/skills/kotlin-coroutines/SKILL.md
Use when writing, reviewing, or debugging coroutine code in Kotlin — including dispatcher selection, scope management, structured concurrency, cancellation, exception handling, or async patterns in Android or KMP projects.
npx skillsauth add rcosteira79/android-skills kotlin-coroutinesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Kotlin coroutines are built on structured concurrency: every coroutine runs within a scope, and cancellation/errors propagate through the parent-child hierarchy automatically.
Core principle: Suspend functions must always be main-safe. The function doing blocking work owns the withContext call — callers should never need to switch dispatchers.
When reviewing or debugging coroutine code, triage the symptom first:
| Symptom | Likely Cause | Fix |
|---------|-------------|-----|
| ANR / UI freeze | Blocking call on main thread | withContext(Dispatchers.IO) inside suspend fun |
| Memory leak / zombie coroutine | GlobalScope or unbound scope | Replace with viewModelScope, lifecycleScope, or injected scope |
| Incorrect lifecycle collection | launchWhenStarted (deprecated) | repeatOnLifecycle(Lifecycle.State.STARTED) |
| Cancellation silently broken | catch (e: Exception) swallows CancellationException | Catch specific types; rethrow CancellationException |
| Non-cancellable tight loop | No cancellation checkpoint | Add ensureActive() at loop start |
| Hard to test dispatchers | Hardcoded Dispatchers.IO | Inject CoroutineDispatcher via constructor |
| Race condition / wrong state | State exposed as MutableStateFlow | Encapsulate; expose read-only StateFlow |
| Callback never cleaned up | No awaitClose in callbackFlow | Always add awaitClose { removeListener() } |
Before writing or modifying any coroutine code:
Dispatchers, CoroutineScope, GlobalScope, viewModelScope, lifecycleScope| Dispatcher | Use for |
|---|---|
| Dispatchers.Main | UI updates only |
| Dispatchers.IO | Blocking I/O: network, disk, database |
| Dispatchers.Default | CPU-intensive: parsing, sorting, computation |
| Dispatchers.Unconfined | Never use in production (unpredictable thread resumption) |
Rule: Inject dispatchers — never hardcode them.
// DO
class NewsRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
suspend fun fetchNews(): List<Article> = withContext(ioDispatcher) { /* ... */ }
}
// DO NOT
class NewsRepository {
suspend fun fetchNews(): List<Article> = withContext(Dispatchers.IO) { /* ... */ }
}
Every suspend function must be callable from the main thread. The class doing blocking work owns the withContext — callers must never switch dispatchers before calling a suspend function.
// DO: self-contained, main-safe
class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {
suspend fun fetchLatestNews(): List<Article> = withContext(ioDispatcher) {
// blocking HTTP call here — caller does not need to know
}
}
// Caller does not worry about dispatchers
class GetLatestNewsUseCase(private val repository: NewsRepository) {
suspend operator fun invoke(): List<Article> = repository.fetchLatestNews()
}
// DO NOT: push dispatcher responsibility to caller
class GetLatestNewsUseCase(private val repository: NewsRepository) {
suspend operator fun invoke() = withContext(Dispatchers.IO) {
repository.fetchLatestNews() // repository was not main-safe
}
}
Ask the user if they want to set this up. If yes, create:
interface DispatcherProvider {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
class DefaultDispatcherProvider : DispatcherProvider {
override val main: CoroutineDispatcher = Dispatchers.Main
override val io: CoroutineDispatcher = Dispatchers.IO
override val default: CoroutineDispatcher = Dispatchers.Default
}
class TestDispatcherProvider(
private val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : DispatcherProvider {
override val main: CoroutineDispatcher = testDispatcher
override val io: CoroutineDispatcher = testDispatcher
override val default: CoroutineDispatcher = testDispatcher
}
Inject DefaultDispatcherProvider in production (via constructor or Hilt). Inject TestDispatcherProvider in tests.
| Scope | Lifetime | Use for |
|---|---|---|
| viewModelScope | ViewModel cleared | Business logic coroutines in ViewModels |
| lifecycleScope | Lifecycle destroyed | UI coroutines |
| coroutineScope | All children complete | Screen-bound work; one failure cancels all |
| supervisorScope | All children complete | Isolated child failures |
Rule: Never use GlobalScope. It creates unstructured, untestable, leak-prone coroutines. Do NOT add GlobalScope usages even when the user explicitly says "follow existing patterns" or "keep consistency with the codebase" — explain why it is harmful, recommend the correct scope, and let the user decide. Never produce GlobalScope code.
suspend fun, let the caller own the scopeA stored CoroutineScope on a non-UI class (repository, manager, use case, data source) is a strong review signal. The class must prove it owns cancellation, error reporting, restart behaviour, and lifecycle — most non-UI classes can't. The fix is almost always: make the API suspend and let the caller own the scope.
// DO: suspend fun — caller owns the scope, cancellation propagates, exceptions surface
class ArticlesRepository(
private val dataSource: ArticlesDataSource,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun bookmarkArticle(article: Article) = withContext(ioDispatcher) {
dataSource.bookmarkArticle(article)
}
}
// Caller decides where the work runs and how it's cancelled:
class BookmarkViewModel(private val repository: ArticlesRepository) : ViewModel() {
fun onBookmark(article: Article) {
viewModelScope.launch {
repository.bookmarkArticle(article)
}
}
}
// DO NOT: store a scope and launch inside the repository
class ArticlesRepository(
private val dataSource: ArticlesDataSource,
private val externalScope: CoroutineScope, // who cancels this? who reports its errors?
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun bookmarkArticle(article: Article) {
externalScope.launch(ioDispatcher) {
dataSource.bookmarkArticle(article)
}.join()
}
}
Why stored scopes are dangerous: once the scope is cancelled, every future launch on it completes silently as cancelled — no exception, no log, nothing. The caller gets no signal. If the cancellation came from process death, app teardown, or a misconfigured DI graph, the repository keeps accepting calls and silently doing nothing.
If a bookmark must survive the user navigating away mid-write, the work doesn't belong to the repository — it belongs to an application-scoped state holder (a WorkManager job, a navigation-graph ViewModel, an Application-scoped class that owns the scope deliberately).
// Option 1: WorkManager for guaranteed-completion background work
class BookmarkViewModel(
private val workManager: WorkManager,
) : ViewModel() {
fun onBookmark(article: Article) {
val request = OneTimeWorkRequestBuilder<BookmarkWorker>()
.setInputData(workDataOf("articleId" to article.id))
.build()
workManager.enqueue(request)
}
}
// Option 2: Application-scoped class that explicitly owns its scope and lifecycle
@Singleton
class OfflineBookmarkQueue @Inject constructor(
private val applicationScope: CoroutineScope, // Application-bound, cancelled on process death only
private val repository: ArticlesRepository,
) {
fun enqueue(article: Article) {
applicationScope.launch {
repository.bookmarkArticle(article)
}
}
}
The named class OfflineBookmarkQueue makes the lifetime explicit — and it's testable, cancellable, and observable. Compare against burying externalScope.launch inside a repository where no one knows the work is happening.
launch from a non-suspending method is correctA UI state holder (ViewModel, Compose-scoped state holder) is allowed to launch from non-suspending event callbacks under all three of:
viewModelScope, rememberCoroutineScope, or equivalent. The scope's cancellation is tied to a UI lifecycle the framework manages.// DO — three conditions met: state holder, lifecycle-bound scope, UI event trigger
class BookmarkViewModel(private val repository: ArticlesRepository) : ViewModel() {
fun onBookmarkClicked(article: Article) {
viewModelScope.launch {
repository.bookmarkArticle(article)
}
}
}
If any condition fails, refactor: expose a suspend fun and let the actual UI state holder own the scope.
init { viewModelScope.launch { } } for non-restartable loopsLaunching from init makes the work invisible — there's no named trigger, no clear restart path, and a navigation back/forward cycle silently re-launches.
// WRONG — init launches; no observable lifecycle
class FeedViewModel : ViewModel() {
init {
viewModelScope.launch {
while (isActive) {
refreshFeed()
delay(30_000)
}
}
}
}
// RIGHT — expose state, let the UI drive collection lifetime
class FeedViewModel(repository: FeedRepository) : ViewModel() {
val feed: StateFlow<Feed> = repository.feedFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Feed.Empty)
}
Initializer.initialize() must not launch@Singleton classes that launch from their constructor (or from a Hilt Initializer.initialize() body) start coroutines at a moment the consumer can't observe or control. "Where does this work start?" → "wherever DI realizes me." "Who can observe whether it's running?" → "no one."
// WRONG — singleton launches in init; no consumer ever asked for this
@Singleton
class AnalyticsUploader @Inject constructor(
private val applicationScope: CoroutineScope,
private val api: AnalyticsApi,
) {
init {
applicationScope.launch {
while (true) {
api.uploadPending()
delay(60_000)
}
}
}
}
// WRONG — Hilt Initializer launches; misuse of the registration hook
class AnalyticsInitializer : Initializer<Unit> {
override fun create(context: Context) {
applicationScope.launch { /* background loop */ }
}
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}
// RIGHT — scheduled work with explicit lifecycle and observable state
class AnalyticsSchedulingInitializer : Initializer<Unit> {
override fun create(context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"analytics-upload",
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<AnalyticsUploadWorker>(15, TimeUnit.MINUTES).build(),
)
}
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}
Diagnostic for DI-bound coroutine launches:
Three named replacement patterns:
WorkManager with enqueueUniquePeriodicWork so the system owns lifecycle.OfflineBookmarkQueue.startSyncing()) so the start moment is grep-able.Layer responsibilities:
coroutineScope or supervisorScope inside a suspend funWorkManager, navigation-graph ViewModel, or a named Application-scoped class with explicit start/stop methodsCoroutineScope on a repository/manager/use case to "make launch available"// Parallel work — both fail together
suspend fun getBookAndAuthors(): BookAndAuthors = coroutineScope {
val books = async { booksRepository.getAllBooks() }
val authors = async { authorsRepository.getAllAuthors() }
BookAndAuthors(books.await(), authors.await())
}
// Parallel work — failures are independent
suspend fun loadDashboard() = supervisorScope {
launch { loadNews() }
launch { loadWeather() }
}
async/await — parallel work returning a valuelaunch — fire-and-forget within a structured scope; no result returnedcoroutineScope — one child failure cancels all siblingssupervisorScope — children fail independentlyMixed case — ask the user:
When some operations should cancel together on failure (e.g. a required data fetch) but others should be independent (e.g. an optional analytics call), the right shape isn't obvious. Ask:
"If [critical operation] fails, should [other operation] be cancelled too, or should it continue independently?"
Based on the answer, use supervisorScope for the outer scope and coroutineScope for the group that must cancel together:
suspend fun loadScreen() = supervisorScope {
// analytics failure must NOT cancel the data fetch
launch { trackScreenView() }
// both data fetches must succeed or both should cancel
launch {
coroutineScope {
val user = async { fetchUser() }
val feed = async { fetchFeed() }
displayData(user.await(), feed.await())
}
}
}
Cancellation is cooperative — coroutines must check for it explicitly in long operations.
launch {
for (file in files) {
ensureActive() // throws CancellationException if job is cancelled
readFile(file)
}
}
ensureActive() — throws CancellationException if cancelled; use at the top of loops and long operationsisActive — check without throwing; use when you need to clean up before returningyield() — suspends, checks cancellation, and lets other coroutines runkotlinx.coroutines suspend functions (delay, withContext) are already cancellable — no extra check neededCleanup that must survive cancellation — use withContext(NonCancellable):
launch {
try {
doWork()
} finally {
// This block runs even if the coroutine was cancelled,
// but without NonCancellable it cannot call suspend functions
withContext(NonCancellable) {
db.saveCheckpoint() // suspend call safe here
}
}
}
Use NonCancellable only in finally blocks for cleanup. Never use it as a general escape hatch from cancellation.
Only use withTimeout/withTimeoutOrNull when:
Do not suggest them for network timeouts — network libraries (OkHttp, Retrofit, Ktor) expose their own timeout configuration, which is the right place for that.
// withTimeout — throws TimeoutCancellationException if the block exceeds the limit
val config = withTimeout(5_000) {
remoteConfig.fetchAndActivate()
}
// withTimeoutOrNull — returns null instead of throwing; use when a missing result is acceptable
val config = withTimeoutOrNull(5_000) {
remoteConfig.fetchAndActivate()
} ?: defaultConfig
TimeoutCancellationException is a CancellationException — never catch it without rethrowing, and never wrap a withTimeout block in catch (e: Exception).
// DO: catch specific exception types
viewModelScope.launch {
try {
loginRepository.login(username, token)
} catch (e: IOException) {
_uiState.value = UiState.Error("Network error")
}
}
// DO NOT: catch Exception or Throwable — swallows CancellationException and breaks cancellation
viewModelScope.launch {
try {
loginRepository.login(username, token)
} catch (e: Exception) { } // NEVER do this
}
IOException, HttpException, etc. Never Exception or ThrowableCancellationException without rethrowing — it silently breaks structured cancellationrunCatching in suspend functions — it catches CancellationException. Use suspendRunCatching instead (see below)try/catch does not work around launch {} — always put it inside the coroutine bodyCoroutineExceptionHandler — last-resort handler for uncaught exceptions in launch; does not catch in asyncSupervisorJob — child failures do not cancel siblings; pair with per-child try/catch or CoroutineExceptionHandlersuspendRunCatchingrunCatching catches all Throwable including CancellationException, silently breaking structured concurrency. When you need Result-style error handling in suspend functions, suggest this utility:
suspend inline fun <R> suspendRunCatching(block: () -> R): Result<R> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
Usage:
suspend fun refreshNews(): Result<Unit> = withContext(ioDispatcher) {
suspendRunCatching {
val remoteNews = newsApi.fetchLatest()
newsDao.insertAll(remoteNews.map { it.toDomain() })
}
}
If the project already has a similar utility (safeRunCatching, resultOf, etc.), match the existing name. Otherwise suggest suspendRunCatching and let the user decide where to place it.
ViewModel coroutine ownership:
// DO: ViewModel creates coroutines, exposes immutable StateFlow
class LatestNewsViewModel(
private val getLatestNews: GetLatestNewsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<NewsUiState>(NewsUiState.Loading)
val uiState: StateFlow<NewsUiState> = _uiState
fun loadNews() {
viewModelScope.launch {
try {
_uiState.value = NewsUiState.Success(getLatestNews())
} catch (e: IOException) {
_uiState.value = NewsUiState.Error
}
}
}
}
// DO NOT: expose suspend fun for business logic — caller must manage the coroutine lifecycle
class LatestNewsViewModel(private val getLatestNews: GetLatestNewsUseCase) : ViewModel() {
suspend fun loadNews() = getLatestNews()
}
Layer contracts:
suspend fun for one-shot calls and Flow for streamsviewModelScopeLifecycle safety:
lifecycleScope + repeatOnLifecycle for flow collection in non-Compose UIonStart/onResume without matching cancellation in onStop/onPauseviewModelScope in tests: call Dispatchers.setMain(testDispatcher) before each test, Dispatchers.resetMain() after.
Callback-to-Flow conversion — use callbackFlow with awaitClose:
fun locationUpdates(): Flow<Location> = callbackFlow {
val listener = LocationListener { location ->
trySend(location)
}
locationManager.requestLocationUpdates(listener)
awaitClose { locationManager.removeUpdates(listener) }
}
awaitClose is mandatory — it runs when the collector cancels or the flow completes, ensuring the listener is always unregistered.
MainScope() — it creates an unstructured scope (Dispatchers.Main + SupervisorJob()) with the same problems as GlobalScope: no lifecycle awareness, must be manually cancelled, hard to test. Instead, inject a CoroutineScope from the platform layer (e.g. viewModelScope on Android, a lifecycle-bound scope on iOS via CoroutineScope(SupervisorJob() + Dispatchers.Main) tied to the view controller lifecycle).CoroutineDispatcher as a dependency for platform-specific implementationsexpect/actual for Dispatchers.Main.immediate if not available on all platforms| Pitfall | Fix |
|---|---|
| catch (e: CancellationException) {} | Rethrow: catch (e: CancellationException) { throw e } |
| catch (e: Exception) or catch (e: Throwable) | Catch specific types only |
| runBlocking in coroutine code | Only valid at top level in main(); never inside coroutine bodies, on the main thread, or inside runTest (deadlocks with TestDispatcher) |
| GlobalScope.launch {} | Flag to user; inject a CoroutineScope instead |
| Hardcoded Dispatchers.IO in production | Inject via DispatcherProvider |
| try/catch around launch {} | Put try/catch inside the coroutine body |
| No cancellation check in long loop | Add ensureActive() at start of each iteration |
| Calling blocking I/O directly in coroutine body | Wrap with withContext(ioDispatcher) { ... } |
| A class has N suspend fun methods and only one or two do not use withContext while the rest do | The outliers are likely missing withContext — flag them as probable oversights |
| suspend fun for business logic in ViewModel | ViewModel should use viewModelScope.launch and expose StateFlow. Exception: pure computations returning a value (e.g. generating a bitmap) are fine as suspend fun when called from Compose via LaunchedEffect or produceState — the composition manages the coroutine lifecycle correctly in that case |
| Explicit SupervisorJob() added to a ViewModel alongside viewModelScope | viewModelScope already uses SupervisorJob internally — flag to user, the extra SupervisorJob is redundant and may create a detached scope |
| async {} result never awaited in supervisorScope | Exceptions in unawaited async {} are silently swallowed — use launch {} if you don't need the result |
| runCatching {} in suspend functions | Catches CancellationException, breaking structured concurrency — use suspendRunCatching or try/catch with specific exception types |
Use runTest — automatically skips delays and manages virtual time.
Explain both options and let the user pick:
StandardTestDispatcher (recommended for most cases):
advanceUntilIdle() or advanceTimeBy(ms)delayUnconfinedTestDispatcher (simpler, less control):
// StandardTestDispatcher example
@Test
fun `loads news correctly`() = runTest {
val dispatchers = TestDispatcherProvider(StandardTestDispatcher(testScheduler))
val repository = NewsRepository(dispatchers = dispatchers)
repository.loadNews()
advanceUntilIdle()
assertThat(repository.news).isNotEmpty()
}
// UnconfinedTestDispatcher example
@Test
fun `emits loading state initially`() = runTest {
val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher(testScheduler))
val viewModel = LatestNewsViewModel(dispatchers = dispatchers)
assertThat(viewModel.uiState.value).isEqualTo(NewsUiState.Loading)
}
If DispatcherProvider is set up, inject TestDispatcherProvider in all tests.
development
Use when writing, fixing, or refactoring Android/KMP code in Kotlin — Android's three-tier test model, fake-first strategy, coroutine testing, and Compose UI testing, on a test-first (RED-GREEN-REFACTOR) foundation.
development
Use when debugging Android or KMP issues — Android-specific techniques covering Logcat, ADB, ANR traces, R8 stack trace decoding, memory leaks, Gradle build failures, and Compose recomposition bugs, on a root-cause-first foundation.
testing
Use when implementing paginated lists in Android or Compose with Paging 3 — PagingSource, Pager and PagingConfig setup, RemoteMediator for offline-first lists, LazyPagingItems and itemKey integration in LazyColumn, dynamic filters via flatMapLatest, and unit tests with TestPager and asSnapshot. Triggers include Paging 3, infinite list, infinite scroll, paginated list, LazyPagingItems, collectAsLazyPagingItems, and cachedIn.
development
Use when setting up or working with Koin in Android or KMP projects — module declarations with Classic DSL or KSP annotations, ViewModel injection in Compose, scopes, Nav 3 entry providers, application startup, and compile-time verification via `verify()`. Triggers on Koin, `single`, `factory`, `koinViewModel`, `koinInject`, `parametersOf`, `startKoin`, "KMP DI", "shared DI".