Kotlin suspend Called Outside a Coroutine: Quick Fix
Launch in lifecycleScope instead of runBlocking: suspend needs a coroutine, and blocking Main to fake one freezes your UI..
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓Basic Kotlin functions and familiarity with callbacks
- ✓An Android project with a ViewModel or Activity
- ✓Awareness of ANRs from Play vitals or logcat
- Suspend functions pause without blocking, so they run only inside coroutines, other suspend funs, or bridges
- Button clicks and callbacks aren't coroutines: wrap the call in lifecycleScope or viewModelScope launch
- runBlocking blocks its whole thread, so on Android Main it freezes the UI until work finishes
- Never use runBlocking on Main or inside another coroutine; reserve it for tests and main() entry points
- You'll wire most screens by propagating suspend through data layers and launching once at the UI boundary
Think of a suspend function as a recipe step that says 'wait an hour, then continue' — the cook starts it, preps other dishes, and returns when the timer rings. But that needs a kitchen with timers and task lists (a coroutine). Your doorbell (a click listener) has no kitchen — it just rings. So you don't cook at the door; you hand the order to the kitchen (launch a coroutine) and let the cooks handle the wait. runBlocking is the cook standing frozen, staring at the oven while orders pile up.
Every Kotlin developer meets this compiler error in week one: 'Suspend function can be called only within a coroutine body.' It's the language guarding its superpower — suspension — from contexts that can't support it. A suspend function can pause without blocking its thread, but only if something provides the machinery: a coroutine scope, another suspend function, or a deliberate bridge. Your onClick listener has none of those, so the compiler stops you.
The danger isn't the error; it's the first 'fix' developers reach for. runBlocking compiles everywhere and freezes the main thread just as universally. You'll ship it, the UI will stutter, then ANR reports will climb in Play vitals while the code looks correct. The proper bridges — launch, async, and suspend propagation — take slightly more thought and behave completely differently at runtime.
This article draws the map. You'll learn which contexts can call suspend code, how launch/async/runBlocking differ, why runBlocking on Main is effectively a self-inflicted ANR, and the patterns that connect buttons to repositories without blocking a single thread.
Where suspend Functions Can Run and Why Plain Callbacks Can't
A suspend function is a function that can pause. When it hits a suspending call — a network await, a delay, a database read — it saves its state, frees its thread for other work, and resumes later where it left off. That trick needs runtime machinery (a continuation plus a coroutine context), and plain functions don't have it. The compiler error is the language refusing to run pausable code in a place with nowhere to pause to.
Four contexts qualify. Another suspend function works because suspension propagates up the chain — ten nested suspend calls still form one coroutine. Coroutine builders (launch, async) create a fresh coroutine with its own context, which is why wrapping a call in viewModelScope.launch satisfies the compiler. runBlocking qualifies by brute force: it blocks the current thread until the coroutine finishes, providing context at the cost of the thread. Tests get a fourth option, runTest, which provides a virtual-time coroutine.
Everything else — click listeners, lifecycle overrides, Java callbacks, getters — is a no. The fix is never to force the call but to connect the contexts: make the caller suspend if it's your code (propagate upward), or launch a coroutine if the caller is a framework callback (bridge at the boundary). That single decision, repeated consistently, is the whole architecture of coroutine apps: suspend inside, launch at the edges.
launch vs async: Fire-and-Forget Against Parallel Results
launch and async are the two builders you'll use daily, and they answer different questions. launch starts fire-and-forget work and returns a Job you can cancel or join — ideal for UI-triggered operations where the result lands in state, not in a return value. Refresh buttons, form submissions, and navigation-driven loads are launch territory. Errors are handled inside with try/catch, since there's no caller awaiting a result.
async answers 'give me this value later' and returns a Deferred<T>. Use it for parallel decomposition: start two fetches, await both, combine. The await() call is itself suspending, so it composes without blocking. Never use async just to move a single call off-thread and immediately await it — that's ceremony with no parallelism. And never forget the await: an async whose result nobody collects silently drops exceptions.
Both builders inherit the scope they're launched in, which is the structured-concurrency guarantee: cancel the scope, and every launched child cancels too. That inheritance is why the scope you choose matters more than the builder. launch in viewModelScope dies with the ViewModel; launch in GlobalScope dies with the process. Same keyword, opposite lifetimes — pick the scope deliberately every time.
runBlocking: the Bridge That Blocks and Its Two Legal Uses
runBlocking is a thread trap dressed as convenience. It starts a coroutine and then parks the calling thread until that coroutine completes — during which the thread does nothing else. On a background thread in a test, that's harmless: the test thread has no other duties. On Android's main thread, it's catastrophic: the parked thread is the one responsible for drawing frames and processing input, so the app freezes solid until the work finishes. Fast wifi hides it; a 3-second mobile stall turns it into an ANR.
Legitimate uses are narrow and recognizable. Unit tests (prefer runTest for virtual time, runBlocking where legacy demands it) may block because test threads exist solely to wait. A JVM main() with no scope yet can bridge once at startup. Command-line tools and migration scripts without a lifecycle qualify. Notice what these share: no UI thread, no event loop, no other work starving while the thread waits.
Inside an app, the answer is always a scope launch plus state. Show loading, launch in lifecycleScope or viewModelScope, render when the result arrives. If legacy code demands a synchronous return, change the contract (callback, Flow, LiveData) rather than blocking for it. Enforce with a lint rule banning runBlocking from the main source set — the shortcut is tempting precisely when it's most dangerous, and automation beats willpower.
main() can afford that; Android Main never can.Bridging Callbacks Without Blocking: Continuations and Flows
Legacy Android APIs speak callback, and blocking a thread while waiting for one is the classic deadlock recipe: the callback often needs the very thread you're holding. suspendCancellableCoroutine is the proper translator. It suspends the current coroutine — freeing its thread — and hands you a continuation object. When the callback fires, you resume the continuation with the value, and the coroutine picks up on the next line as if the call had been synchronous all along. Cancellation flows backward too: if the coroutine is cancelled while waiting, your invokeOnCancellation handler unregisters the callback.
Flows cover the repeating case. Wrap event streams with callbackFlow: offer each event into the flow, awaitClose to unregister, and let collectors receive values with all of Flow's operators (debounce, mapLatest, retry). Collect with repeatOnLifecycle so collection pauses with the UI automatically. Repositories expose Flow or suspend functions; ViewModels collect or call them; screens observe state. Callbacks survive only inside these two wrappers, quarantined at the boundary.
Java callers need the reverse bridge since they can't suspend. Expose a normal function that launches in a scope and reports through a callback, or publish a LiveData/StateFlow they observe. Keep the suspend function as the core and add the Java-friendly overload beside it — one implementation, two doors. That way the coroutine path stays clean while legacy callers keep working.
The Target Architecture: Suspend Inside, Launch at the Edges
The target architecture is boring in the best way: suspend functions all the way down, launches only where frameworks demand. Repositories expose suspend fun load() — no scopes, no jobs, trivially testable with runTest. Use-cases compose them with plain sequential code that reads like synchronous logic. ViewModels bridge once with viewModelScope.launch, converting results into observable state. Activities and fragments observe that state and launch UI-tied work in lifecycleScope. Each layer has one job, and suspension flows through the middle untouched.
Testing this stack is pleasant because suspend functions are direct: call them in runTest, assert the return, simulate errors with fakes, advance virtual time past delays. No latches, no idling resources, no flakiness. The launches at the boundary get tested through state assertions — trigger the event, advance time, check the exposed state. Turbine makes Flow assertions equally direct. If a layer is hard to test, it's usually holding a scope it shouldn't; push the launch outward until the logic is bare suspend again.
Enforce the shape with two rules. First, util and data packages contain no launch and no GlobalScope — only suspend functions and Flows. Second, runBlocking appears only in test sources and main() entry points, enforced by lint. With those gates, every suspend-context error becomes a design hint (which layer owns this launch?) instead of a compile-time annoyance papered over with a blocking bridge.
Migrating a Callback Codebase Without Stopping the World
Rollout order matters when retrofitting. Start with the leaf that hurts most — the repository call wrapped in runBlocking or callbacks — and convert it to suspend with a test. Update its direct callers: ViewModels launch, other suspend functions just call through. Each conversion deletes glue code (latches, callbacks, blocking waits) instead of adding it, so the diff trend stays negative and reviewers stay happy.
Handle the human side deliberately. The suspend-across-layers style reads strangely to engineers raised on callbacks — 'where does this run?' is the common anxiety. Answer it once in a team doc: dispatchers decide threads, scopes decide lifetimes, and sequential suspend code is automatically correct on both. Pair the first two conversions, then let the pattern spread by imitation. Most teams cross the tipping point within a sprint.
Measure what you fixed. ANR rate in Play vitals should fall as runBlocking leaves Main; frozen-frame percentages follow. Flaky-test rates drop as latches become runTest assertions. Track both for two releases after the migration and share the graphs — nothing sustains an architecture like visible proof it worked. The compiler error that started the journey becomes rare, and when it appears, it's a five-minute fix at a known boundary instead of a design crisis.
runBlocking in BaseActivity Froze Checkout on Slow Networks
- runBlocking on Main is a latency gamble: fast networks hide it, slow networks turn it into ANRs.
- One blocking call in shared code (a base activity!) multiplies across every screen that inherits it.
- Ban runBlocking from app source sets with lint so the shortcut can't be reintroduced.
latch.await(), future.get(), or runBlocking while the awaited work is scheduled on the same pool. Convert the waiter to suspendCancellableCoroutine or restructure with async/await so no thread is held.| File | Command / Code | Purpose |
|---|---|---|
| Contexts.kt | suspend fun loadUser(id: String): User = api.fetchUser(id) | Where suspend Functions Can Run and Why Plain Callbacks Can' |
| Builders.kt | fun refresh(viewModel: FeedViewModel) { | launch vs async |
| Bridges.kt | @Test | runBlocking |
| CallbackBridge.kt | suspend fun awaitLocation(client: LocationClient): Location = | Bridging Callbacks Without Blocking |
Key takeaways
main(), an ANR machine on Android Main.Common mistakes to avoid
5 patternsCalling a suspend function directly from onClick or a Java callback
Using runBlocking on Android's main thread to 'just wait' for a result
main() bridges. In apps, replace it with a proper scope launch and move result handling into the coroutine or a callback.Blocking a thread to wait for a coroutine result (CountDownLatch, .get())
Hiding GlobalScope.launch inside utility functions
Calling runBlocking from within a coroutine (nested blocking)
main() with a Dispatchers.Default context and a comment explaining why no scope exists there.Interview Questions on This Topic
What is a suspend function and where can it be called?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's Kotlin. Mark it forged?
5 min read · try the examples if you haven't