AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Kotlin Coroutines

skill-rcosteira79-android-skills-kotlin-coroutines · by rcosteira79

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.

No reviews yet
0 installs
38 views
0.0% view→install

Install

$ agentstack add skill-rcosteira79-android-skills-kotlin-coroutines

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-rcosteira79-android-skills-kotlin-coroutines)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
3mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Kotlin Coroutines? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Kotlin Coroutines

Overview

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.

Diagnosing Coroutine Issues

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() } |

Step 1: Project Context Check

Before writing or modifying any coroutine code:

  1. Search for Dispatchers, CoroutineScope, GlobalScope, viewModelScope, lifecycleScope
  2. Identify how dispatchers are injected (or not)
  3. Identify exception handling patterns in use
  4. If approach is sound: match it
  5. If approach violates rules below: explain why to the user, recommend the correct approach, and let them decide before changing anything — do NOT produce code that follows the bad pattern
  6. Beyond violations: also look for places where the patterns in this skill could simplify existing code — manual scope management that structured concurrency could clean up, unnecessary coroutine overhead, etc.

Dispatcher Selection

| 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 = withContext(ioDispatcher) { /* ... */ }
}

// DO NOT
class NewsRepository {
    suspend fun fetchNews(): List = withContext(Dispatchers.IO) { /* ... */ }
}

Main-Safety Rule

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 = 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 = 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
    }
}

DispatcherProvider Pattern

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 Management

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

Scope ownership: prefer suspend fun, let the caller own the scope

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

When work must outlive the caller — fire-and-forget

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()
            .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.

State-holder carve-out — when launch from a non-suspending method is correct

A UI state holder (ViewModel, Compose-scoped state holder) is allowed to launch from non-suspending event callbacks under all three of:

  1. It is a state holder for a UI surface. Not "feels like a state holder" — actually owns UI state that the view layer collects.
  2. It uses a lifecycle-bound scopeviewModelScope, rememberCoroutineScope, or equivalent. The scope's cancellation is tied to a UI lifecycle the framework manages.
  3. The trigger is a UI event — a click, swipe, key press, lifecycle event. Not a repository call, not a background timer, not a DI hook.
// 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.

Anti-pattern: init { viewModelScope.launch { } } for non-restartable loops

Launching 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 = repository.feedFlow
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Feed.Empty)
}

DI-bound singleton anti-pattern — 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 {
    override fun create(context: Context) {
        applicationScope.launch { /* background loop */ }
    }
    override fun dependencies() = emptyList>>()
}

// RIGHT — scheduled work with explicit lifecycle and observable state
class AnalyticsSchedulingInitializer : Initializer {
    override fun create(context: Context) {
        WorkManager.getInstance(context).enqueueUniquePeriodicWork(
            "analytics-upload",
            ExistingPeriodicWorkPolicy.KEEP,
            PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build(),
        )
    }
    override fun dependencies() = emptyList>>()
}

Diagnostic for DI-bound coroutine launches:

  • "Where is the start moment defined?" If "wherever DI realizes me," bad.
  • "Who can observe whether the work is running?" If "no one," bad.
  • "Can the work be restarted independently?" If "no, only by restarting the process," bad.

Three named replacement patterns:

  1. Invert into the consumer — delete the background-loop class; let the consumer collect or call directly.
  2. Scheduled work — use WorkManager with enqueueUniquePeriodicWork so the system owns lifecycle.
  3. Explicit named launch site — if the work really must run, put it in a named class with a named method (OfflineBookmarkQueue.startSyncing()) so the start moment is grep-able.

Layer responsibilities:

  • Work tied to current screen → coroutineScope or supervisorScope inside a suspend fun
  • Work that genuinely outlives the screen → WorkManager, navigation-graph ViewModel, or a named Application-scoped class with explicit start/stop methods
  • Never: stored CoroutineScope on a repository/manager/use case to "make launch available"

Structured Concurrency

// 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 value
  • launch — fire-and-forget within a structured scope; no result returned
  • coroutineScope — one child failure cancels all siblings
  • supervisorScope — children fail independently

Mixed 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

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 operations
  • isActive — check without throwing; use when you need to clean up before returning
  • yield() — suspends, checks cancellation, and lets other coroutines run
  • All kotlinx.coroutines suspend functions (delay, withContext) are already cancellable — no extra check needed

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

Timeouts

Only use withTimeout/withTimeoutOrNull when:

  • The codebase already uses them — match the patt

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.