Technical blog from Craig Russell.
This post describes a pattern of launching coroutines which cancel when the Activity or ViewModel is destroyed, but support allowing important parts of the coroutine to run uncancelled.
When you have an Activity and a ViewModel, you need to pay attention to the scopes in which you launch coroutines. Launching a coroutine from an Activity will result in it having a different lifecycle than if launched from ViewModel. And what happens if you have async work that you don’t want to cancel if the Activity or ViewModel are destroyed?

In our codebase, we typically marked our ViewModel functions with suspend when they would involve asynchronous work, and launched the coroutine from the Activity. Part of the rationale for this was that it made testing much easier. We knew how to write a test for a suspend function.
Let’s consider this example; we have the TabSwitcherActivity which is used to show users their open tabs and let them switch, add, and close tabs.

For brevity, let’s just focus on the close tab functionality.
class TabSwitcherActivity : CoroutineScope by MainScope() {
fun onTabDeleted(tab: TabEntity) {
launch { viewModel.onTabDeleted(tab) }
}
}
class TabSwitcherViewModel {
suspend fun onTabDeleted(tab: TabEntity) {
tabRepository.delete(tab)
}
}
class TabRepository {
suspend fun delete(tab: TabEntity) {
deleteOldPreviewImages(tab.tabId)
tabsDao.deleteTabAndUpdateSelection(tab)
}
}
ActivityActivity launches a coroutine, and calls delete method on ViewModelViewModel calls delete method on TabRepositoryTabRepository deletes the tab from the databaseThere is a problem looming here. The reason we are using coroutines at all in this flow is because deleting from the database is an asynchronous operation; it might not happen immediately since it involves disk IO.
What happens if the user navigates away from the Activity immediately after hitting the delete button but before the tab is deleted? Consider this sequence 👇
If the Activity is destroyed, the coroutine will be cancelled and we might fail to honor the user’s decision to delete the tab. This is dangerous! ⚠️ If the user thinks the tab will be deleted, we can’t disregard that just because the user hit the back button or rotated their phone.
Activity which is now destroyed.ViewModel instead of the Activity.
Activity, the Activity, ViewModel and coroutine will all be destroyed and cancelled and we’ll still fail to honor the user’s action.GlobalScope.launch from the Repository.
Snackbar when the tab was deleted for instance, it would be trickier to achieve with this approach.Thread and firing that off to do the deletion. ❌Repository, we can use NonCancellable to ensure the deletion cannot be cancelled once started. ✅NonCancellable ✨A non-cancelable job that is always active. It is designed for
withContextfunction to prevent cancellation of code blocks that need to be executed without cancellation.
Well now, that sounds pretty handy for our scenario. Let’s explore NonCancellable further, and see what the code looks like for our TabRepository. As it turns out, it requires just a tiny change to our TabRepository to get this new behavior.
suspend fun delete(tab: TabEntity) {
withContext(Dispatchers.IO + NonCancellable) {
deleteTabImagePreview(tab)
tabsDao.deleteTab(tab)
}
}
Within a coroutine, we can mark a block as NonCancellable to ensure it runs to completion once started.
Activity which launched the coroutine is still around, it’ll be able to take action to confirm deletion with the user (e.g., show a Snackbar)Activity is destroyed, the coroutine it launched should be cancelled too. However, with NonCancellable, we ensure that the tab will be deleted (as long as the app itself isn’t killed)Activity is already dead, nothing in the coroutine after the block marked with NonCancellable will execute; the refusal to be stopped applies only to code within the block.