Coroutines & Patterns for work that shouldn’t be cancelled

Cancellation and Exceptions in Coroutines (Part 4)

In part 2 of the Cancellation and Exceptions in Coroutines series, we learnt the importance of cancelling work when it’s no longer needed. On Android, you can use the CoroutineScope s provided by Jetpack: viewModelScope or lifecycleScope that cancel any running work when their scope completes — that is when the Activity/Fragment/Lifecycle completes. If you’re creating your own CoroutineScope , make sure you tie it to a Job and call cancel when needed.

However, there are cases when you want an operation to complete even if the user navigated away from a screen. As such, you don’t want the work to be cancelled (e.g. writing to a database or making a certain network request to your server).

Keep reading for a pattern to achieve this!

Coroutines or WorkManager?

Coroutines will run as long as your application process is alive. If you need to run operations that should outlive the process (e.g. sending logs to your remote server), use WorkManager instead on Android. WorkManager is the library to use for critical operations that are expected to execute at some point in the future.

Use coroutines for operations that are valid in the current process and can be cancelled if the user kills the app (e.g. making a network request you want to cache). What’s the pattern to trigger these operations?

Coroutines best practices

Since this pattern builds upon other coroutine best practices; let’s recap them:

1. Inject Dispatchers into classes

Don’t hardcode them when creating new coroutines or calling withContext .

✅ Benefits: ease of testing as you can easily replace them for both unit and instrumentation tests.

2. The ViewModel/Presenter layer should create coroutines

If it’s a UI-only operation, then the UI layer can do it. If you think this is not possible in your project, it’s likely you’re not following best practice #1 (i.e. it’s more difficult to test VMs that don’t inject Dispatchers ; in that case exposing suspend functions makes it doable).

✅ Benefits: The UI layer should be dumb and not directly trigger any business logic. Instead, defer that responsibility to the ViewModel/Presenter layer. Testing the UI layer requires instrumentation tests in Android which need an emulator to run.

3. The layers below the ViewModel/Presenter layer should expose suspend functions and Flows

If you need to create coroutines, use coroutineScope or supervisorScope . If you need them to follow a different scope, this is what this article is about! Keep reading!

✅ Benefits: The caller (generally the ViewModel layer) can control the execution and lifecycle of the work happening in those layers, being able to cancel when needed.

Operations that shouldn’t be cancelled in Coroutines

Imagine we have a ViewModel and a Repository in our app with the following logic:

class MyViewModel(private val repo: Repository) : ViewModel() {

fun callRepo() {

viewModelScope.launch {

repo.doWork()

}

}

} class Repository(private val ioDispatcher: CoroutineDispatcher) {

suspend fun doWork() {

withContext(ioDispatcher) {

doSomeOtherWork()

veryImportantOperation() // This shouldn’t be cancelled

}

}

}

We don’t want veryImportantOperation() to be controlled by viewModelScope as it could be cancelled at any point. We want that operation to outlive viewModelScope . How can we achieve that?

To do this, create your own scope in the Application class and call those operations in coroutines started by it. That scope should be injected in the classes that need it.

The benefits of creating your own CoroutineScope vs other solutions we’ll see later (like GlobalScope ) is that you can configure it as you wish. Do you need a CoroutineExceptionHandler ? Do you have your own thread pool you use as a Dispatcher ? Place all that common configuration there in its CoroutineContext !

You can call it applicationScope and it must contain a SupervisorJob() so that failures in coroutines don’t propagate in the hierarchy (as seen in part 3 of the series):

class MyApplication : Application() {

// No need to cancel this scope as it'll be torn down with the process

val applicationScope = CoroutineScope(SupervisorJob() + otherConfig)

}

We don’t need to cancel this scope since we want it to remain active as long as the application process is alive, so we don’t hold a reference to the SupervisorJob . We can use this scope to run coroutines that need a longer lifetime than the calling scope might offer in our app.

For operations that shouldn’t be cancelled, call them from a coroutine created by an application CoroutineScope

Whenever you create a new Repository instance, pass in the applicationScope we created above. For tests, check out the Testing section below.

Which coroutine builder to use?

Depending on veryImportantOperation ’s behavior, you’d need to start a new coroutine using either launch or async:

If it needs to return a result, use async and call await to wait for it to finish.

and call to wait for it to finish. If not, use launch and wait for it to finish with join . Note that as explained in part 3 of the series, you have to handle exceptions manually inside the launch block.

This is how you’d trigger the coroutine using launch :

class Repository(

private val externalScope: CoroutineScope,

private val ioDispatcher: CoroutineDispatcher

) {

suspend fun doWork() {

withContext(ioDispatcher) {

doSomeOtherWork()

externalScope.launch {

// if this can throw an exception, wrap inside try/catch

// or rely on a CoroutineExceptionHandler installed

// in the externalScope's CoroutineScope

veryImportantOperation()

}.join()

}

}

}

or using async :

class Repository(

private val externalScope: CoroutineScope,

private val ioDispatcher: CoroutineDispatcher

) {

suspend fun doWork(): Any { // Use a specific type in Result

withContext(ioDispatcher) {

doSomeOtherWork()

return externalScope.async {

// Exceptions are exposed when calling await, they will be

// propagated in the coroutine that called doWork. Watch

// out! They will be ignored if the calling context cancels.

veryImportantOperation()

}.await()

}

}

}

In any case, the ViewModel code doesn’t change and with the above, even if the viewModelScope gets destroyed, the work using externalScope will keep running. Furthermore, doWork() won’t return until veryImportantOperation() completes as with any other suspend call.

What about something simpler?

Another pattern that could serve some use cases (and it’s probably the first solution anyone would come up with) is wrapping veryImportantOperation in the externalScope ’s context using withContext as follows:

class Repository(

private val externalScope: CoroutineScope,

private val ioDispatcher: CoroutineDispatcher

) {

suspend fun doWork() {

withContext(ioDispatcher) {

doSomeOtherWork()

withContext(externalScope.coroutineContext) {

veryImportantOperation()

}

}

}

}

However, this approach has some caveats that you should be aware of:

If the coroutine that calls doWork is cancelled while veryImportantOperation is getting executed, it will keep executing until the next cancellation point, not after veryImportantOperation finishes executing.

is cancelled while is getting executed, it will keep executing until the next cancellation point, not after finishes executing. CoroutineExceptionHandler s don’t work as you’d expect when the context is used in withContext since the exception will be re-thrown.

Testing