Understanding SupervisorJob in Kotlin Coroutines
In this article, you will learn what SupervisorJob is, how it works under the hood, and explored real-world use cases from viewModelScope and the RevenueCat SDK.

Coroutines are a powerful, language-level feature in Kotlin for asynchronous programming, and they’ve become the official solution across Android, Kotlin Multiplatform, and even backend development.
Coroutines are lightweight alternatives to threads. They can suspend without blocking system resources, making them highly efficient and well-suited for fine-grained concurrency. Kotlin provides useful coroutine APIs, such as builders, coroutine contexts, Job
, and Dispatchers
, that give you sophisticated ways to manage complex and nuanced concurrency scenarios.
In this article, you’ll dive into one of these concepts, SupervisorJob
, exploring how it works, the internal mechanisms behind it, and how the RevenueCat SDK for Android leverages it to handle asynchronous programming.
Understanding the core difference: Job vs. SupervisorJob
In Kotlin’s structured concurrency, the default behavior of a Job
is to enforce a “one-for-all, all-for-one” policy. When any child coroutine in a scope fails with an exception, it immediately cancels its parent, which in turn cancels all other sibling coroutines. This is a safe and predictable default for many use cases, but it’s not always desirable.
The SupervisorJob
is a specialized type of Job
designed to break this rigid failure propagation. Its purpose is to create a scope where children can fail independently without affecting the supervisor job itself or its other children. This enables fault isolation, an important pattern for building resilient applications where one failing task should not bring down the entire system.
Let’s see what are the differences between a Job
and SupervisorJob
:
Job
(the default) enforces strict structured concurrency. Failure is propagated upwards. If a child coroutine launched within a coroutineScope
or CoroutineScope(Job())
fails with an exception, it cancels its parent job. The parent then immediately cancels all of its other children.
1val scope = CoroutineScope(Job())
2scope.launch { /* Child 1 */ }
3scope.launch { throw Exception("Child 2 Failed!") } // This will cancel the parent Job
4scope.launch { /* Child 3 */ } // This will be cancelled too
SupervisorJob
(the resilient parent) modifies the cancellation behavior. Failure is not propagated upwards. If a child coroutine launched within a CoroutineScope(SupervisorJob())
fails, it does not affect the parent SupervisorJob
or any of its other children. The failure is isolated to that specific child.
1val scope = CoroutineScope(SupervisorJob())
2scope.launch { /* Child 1 will keep running */ }
3scope.launch { throw Exception("Child 2 Failed!") } // This failure is isolated
4scope.launch { /* Child 3 will keep running
Internal mechanisms of SupervisorJob
If you look into the API surface of SupervisorJob(parent)
function is a simple factory that instantiates a private implementation class, SupervisorJobImpl
.
1@Suppress("FunctionName")
2public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)
3
4private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
5 override fun childCancelled(cause: Throwable): Boolean = false
6}
The entire implementation of SupervisorJobImpl
is a single-line override. To understand this, we must first understand what it is overriding.
A standard Job
, created via Job()
, is an instance of JobImpl
, which inherits its behavior from JobSupport
. In JobSupport
, when a child coroutine fails, it eventually calls childCancelled(cause)
on its parent’s ChildHandle
. The default implementation of this method in a standard Job
is designed to propagate the failure upwards:
1// In a regular JobSupport instance
2public open fun childCancelled(cause: Throwable): Boolean {
3 // A CancellationException is considered normal, don't cancel the parent.
4 if (cause is CancellationException) return true
5
6 // For any other exception, cancel the parent itself.
7 return cancelImpl(cause)
8}
This is the mechanism of failure propagation. A non-cancellation exception from a child causes the parent to cancel itself.
On the other hand, SupervisorJobImpl
overrides this method with a single, simple statement:
1override fun childCancelled(cause: Throwable): Boolean = false
It looks very simple, but it behaves more than just simple:
- The Override: By always returning
false
, theSupervisorJob
is telling the coroutine machinery: “A child has notified me of its failure, but I have not handled the exception, and I will not be cancelling myself because of it.” - The Effect: This effectively breaks the upward propagation of failure. The child’s failure is contained. The supervisor job remains active, and its other children continue to run unaffected. The responsibility for handling the failed child’s exception is now delegated elsewhere, typically to a
CoroutineExceptionHandler
in the child’s context or by the consumer of anasync
Deferred
result.
This small but profound change is the entire internal mechanism that defines a SupervisorJob
.
The supervisorScope
builder
The supervisorScope
function provides a convenient way to create an ad-hoc supervisory scope. Its internal mechanism is to create a temporary, specialized coroutine, SupervisorCoroutine
, which acts as the root of the new scope.
1public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
2 // ...
3 val coroutine = SupervisorCoroutine(uCont.context, uCont)
4 // ...
5}
6
7private class SupervisorCoroutine<in T>(...) : ScopeCoroutine<T>(...) {
8 override fun childCancelled(cause: Throwable): Boolean = false
9}
Just like SupervisorJobImpl
, the SupervisorCoroutine
is a specialized coroutine whose only unique feature is that it overrides childCancelled
to return false
. When you launch new coroutines inside a supervisorScope
block, their parent Job
will be this SupervisorCoroutine
. Therefore, any failures in those children will be stopped at the SupervisorCoroutine
, preventing the failure from leaking out and cancelling the outer scope that called supervisorScope
.
Real-world use cases
The RevenueCat Purchases SDK manages in-app subscriptions, demonstrating this principle perfectly through its strategic use of SupervisorJob
in its BlockstoreHelper
class. The BlockstoreHelper
has a clever and specific job: it interacts with Google’s Block Store API to back up and restore an anonymous user ID. This is a user-friendly feature designed to help users seamlessly restore their purchases on a new device, even if they haven’t created an account.
An exception thrown during a Block Store operation must not be allowed to propagate and crash the host application, so the BlockstoreHelper
achieves this necessary isolation by defining its own dedicated CoroutineScopes
, each configured with a SupervisorJob
.
1internal class BlockstoreHelper
2constructor(
3 // ... dependencies
4 private val ioScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)),
5 private val mainScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main),
6) {
7 // ...
8}
So, any suspend function running inside ioScope
or mainScope
won’t propagate failures to other requests within the same scope, allowing them to continue running safely.
Another great example of use cases of SupervisorJob
is the viewModelScope
in Android ViewModel
. A ViewModel
often manages multiple, independent, and long-running tasks that should not interfere with each other.
Imagine a user profile screen in a social media app. The ViewModel
needs to:
- Fetch the user’s profile information.
- Observe a real-time Flow of incoming messages.
- Listen for updates to the user’s “online” status from another Flow.
These are three independent operations. If the message Flow fails due to a network blip, you certainly don’t want to stop fetching the user’s profile or listening for their online status.
You’re probably already using viewModelScope
naturally and without any doubts, often following official examples like the ones shown below:
1class ProfileViewModel(
2 private val profileRepository: ProfileRepository,
3 private val chatRepository: ChatRepository,
4 private val statusRepository: StatusRepository
5) : ViewModel() {
6
7 // viewModelScope uses a SupervisorJob by default!
8 // It's equivalent to CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
9
10 fun fetches() {
11 // Task 1: Fetch profile data
12 viewModelScope.launch {
13 val profile = profileRepository.fetchUserProfile()
14 // ..
15 }
16
17 // Task 2: Observe incoming messages
18 viewModelScope.launch {
19 val messages = chatRepository.getIncomingMessages()
20 // ..
21 }
22
23 // Task 3: Observe online status
24 viewModelScope.launch {
25 val status = statusRepository.getOnlineStatus()
26 // ..
27 }
28 }
29}
The viewModelScope
extension property wisely uses a SupervisorJob
under the hood since the purpose of ViewModel
is clear. If the getIncomingMessages
suspend function throws an unhandled exception, only that specific launch
block will fail. The other two coroutines, fetching the profile and observing the online status, will continue to run unaffected. Without a SupervisorJob
, a single network error in the chat system would crash the entire ViewModel
‘s scope, potentially leaving the UI in an inconsistent and unresponsive state.
If you explore the internal codes of viewModelScope
, you will notice it’s already using the SupervisorJob
:
1public val ViewModel.viewModelScope: CoroutineScope
2 get() =
3 synchronized(VIEW_MODEL_SCOPE_LOCK) {
4 getCloseable(VIEW_MODEL_SCOPE_KEY)
5 ?: createViewModelScope().also { scope ->
6 addCloseable(VIEW_MODEL_SCOPE_KEY, scope)
7 }
8 }
9
10internal fun createViewModelScope(): CloseableCoroutineScope {
11 val dispatcher =
12 try {
13 Dispatchers.Main.immediate
14 } catch (_: NotImplementedError) {
15 EmptyCoroutineContext
16 } catch (_: IllegalStateException) {
17 EmptyCoroutineContext
18 }
19 return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob()) // here!
20}
Conclusion
In this article, you’ve learned what SupervisorJob
is, how it works under the hood, and explored real-world use cases from viewModelScope
and the RevenueCat SDK. By understanding its unique failure-handling behavior, you can design coroutine hierarchies that are more resilient, preventing one failing child from cancelling an entire scope.
As always, happy coding!
— Jaewoong
You might also like
- Blog post
Exploring Modifier.Node for creating custom Modifiers in Jetpack Compose
In this article, you will learn how to create custom modifiers using the three primary APIs, Modifier.then(), Modifier.composed(), and Modifier.Node
- Blog post
remember vs rememberSaveable: deep dive into state management and recomposition in Jetpack Compose
Understanding the differences between remember and rememberSaveable by exploring their internal mechanisms, and how they relate to state and recomposition.
- Blog post
Mark your models as stable with the Compose runtime annotation library
In this article, we’ll look at how to address this issue using the new compose-runtime-annotation library.