Exercise Caution When Using runBlocking on Android

New
7 min read
Jaewoong E.
Jaewoong E.
Published January 31, 2025

As Kotlin continues to dominate as the preferred language for Android development, developers must understand its underlying mechanisms. One of Kotlin's standout features is its built-in support for asynchronous and non-blocking programming at the language level, offering developers powerful tools to build efficient and responsive applications.

Coroutines in Kotlin can be created using coroutine builders—specialized functions designed to initiate and manage coroutines. Common builders include launch, async, and runBlocking. Among these, runBlocking is frequently featured in Kotlin's official documentation to demonstrate coroutine usage, such as within the main() function. As a result, many Android developers are already familiar with its use cases.

So, how does runBlocking work under the hood? In this article, we will delve into its internal mechanism and explore why it should be used cautiously by looking at some sample cases, particularly in Android development.

Understanding runBlocking

If you’ve explored Coroutines references, you’ve likely encountered code like the example below across official Kotlin documentation, articles, sample projects, and other resources:
https://gist.github.com/skydoves/c03bbfe4e734b62867e3ae72f03404d7#file-main-kt

Sample code examples frequently showcase runBlocking within the main function, which might lead you to think, "Oh, this is amazing. I can call any suspend function without needing a coroutine scope and even retrieve its result effortlessly. This is convenient!"

Nowadays, most developers are aware of using viewModelScope to launch coroutines safely, ensuring jobs are automatically canceled in alignment with the ViewModel's lifecycle in Android development. However, when Coroutines were first introduced, it was a common mistake for developers to write code like the example below:

kt
1
2
3
4
5
6
7
class MainViewModel(private val mainRepository: MainRepository) : ViewModel() { // you shouldn't do this private fun fetchPosters() = runBlocking { mainRepository.fetchPosters() } }

This approach worked seamlessly without introducing noticeable delays or issues in the application because fetching a small chunk of data from the network is not a big deal in most cases. Why can this approach pose a problem? According to the official documentation, runBlocking launches a new coroutine and blocks the current thread until the coroutine's execution is complete.

On the Android system, the main thread is responsible for rendering screens and handling UI interactions. If the main thread is blocked or utilized for tasks like I/O operations, it can lead to screen freezes or even cause an Application Not Responding (ANR). So, using runBlocking in the Android UI code to execute I/O tasks, such as querying a database or fetching data from the network, poses significant risks in Android development and should be avoided.

Examining runBlocking internals

Let’s delve into the internal implementation of runBlocking to understand how it operates under the hood:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public actual fun <T> runBlocking(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } val currentThread = Thread.currentThread() val contextInterceptor = context[ContinuationInterceptor] val eventLoop: EventLoop? val newContext: CoroutineContext if (contextInterceptor == null) { // create or use private event loop if no dispatcher is specified eventLoop = ThreadLocalEventLoop.eventLoop newContext = GlobalScope.newCoroutineContext(context + eventLoop) } else { // See if context's interceptor is an event loop that we shall use (to support TestContext) // or take an existing thread-local event loop if present to avoid blocking it (but don't create one) eventLoop = (contextInterceptor as? EventLoop)?.takeIf { it.shouldBeProcessedFromContext() } ?: ThreadLocalEventLoop.currentOrNull() newContext = GlobalScope.newCoroutineContext(context) } val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop) coroutine.start(CoroutineStart.DEFAULT, coroutine, block) return coroutine.joinBlocking() }

If you examine the internal implementation of runBlocking, you'll find that it launches a new coroutine on the current thread while leveraging the GlobalScope to derive the coroutine context.

It initializes a new instance of BlockingCoroutine, and upon examining the internal implementation of BlockingCoroutine, particularly the joinBlocking method, it becomes evident that this method blocks and occupies the current thread entirely until all tasks are completed.

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private class BlockingCoroutine<T>( parentContext: CoroutineContext, private val blockedThread: Thread, private val eventLoop: EventLoop? ) : AbstractCoroutine<T>(parentContext, true, true) { .. @Suppress("UNCHECKED_CAST") fun joinBlocking(): T { registerTimeLoopThread() try { eventLoop?.incrementUseCount() try { while (true) { @Suppress("DEPRECATION") if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) } val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE // note: process next even may loose unpark flag, so check if completed before parking if (isCompleted) break parkNanos(this, parkNanos) } } finally { // paranoia eventLoop?.decrementUseCount() } } finally { // paranoia unregisterTimeLoopThread() } // now return result val state = this.state.unboxState() (state as? CompletedExceptionally)?.let { throw it.cause } return state as T } }

As demonstrated in the code above, the BlockingCoroutine blocks the current thread by executing a while (true) infinite loop. It continuously processes events (blocking) from the current thread's event loop, breaking the infinite loop (and unblocking) only when the coroutine job is completed. Ultimately, this represents a synchronization task that halts the current thread until the launched coroutine task finishes execution.

For this reason, it is essential to use runBlocking cautiously to avoid blocking the Android main thread. Doing so can result in an ANR, severely impacting your application's performance and user experience.

Why is using runBlocking on Android problematic?

Let’s delve into why runBlocking can pose challenges in Android development by examining the provided sample code examples.

The first example

You might now be curious about what happens when you set the dispatcher to Dispatchers.IO for launching coroutines, as shown in the example below:

kt
1
2
3
4
5
6
fun sample1() = runBlocking(Dispatchers.IO) { val currentThread = Thread.currentThread() Log.d("tag_main", "currentThread: $currentThread") delay(3000) // a task that takes 3 seconds Log.d("tag_main", "job completed") }

It might seem like everything should work as expected since we’ve switched to using Dispatchers.IO to launch the coroutines on a background thread. However, when you execute the function, it will produce logs similar to the output shown below:

11:50:07.077 17067-17067 onCreate
11:50:07.094 17067-17092 currentThread: Thread[DefaultDispatcher-worker-2,5,main]
11:50:10.100 17067-17092 job completed

As evident from the log output above, it takes 3 seconds before the "job completed" log message is printed. Although the delay(3000) function runs on a worker thread, the main thread remains blocked, waiting for the coroutine task to complete. Consequently, the entire UI will remain frozen for 3 seconds, causing the application to become unresponsive during this period. Running the coroutine on a different thread using Dispatchers.IO on runBlocking does not achieve true asynchronous behavior in this scenario.

The second example

What happens if you use Dispatchers.Main instead of Dispatchers.IO with the runBlocking function? Since runBlocking operates on the main thread by default, it should theoretically work as expected with the sample code shown below:

kt
1
2
3
4
5
6
fun sample2() = runBlocking(Dispatchers.Main) { val currentThread = Thread.currentThread() Log.d("tag_main", "currentThread: $currentThread") delay(3000) Log.d("tag_main", "job completed") }

However, if you run the function above, you'll see the log result below:

12:05:42.802 17827-17827 onCreate
Ready to integrate? Our team is standing by to help you. Contact us today and launch tomorrow!

Here’s something interesting: even the log message about the current thread is not printed, indicating that the function was blocked during the execution of runBlocking(Dispatchers.Main). Additionally, the UI remains frozen indefinitely and fails to render any layouts on the screen.

This happens because the runBlocking function inherently blocks the main thread to launch a new coroutine scope. However, the coroutine scope attempts to switch the context to Dispatchers.Main, leading to a deadlock. Since the main thread is already occupied by runBlocking, it cannot process the coroutine on the same thread, causing a complete deadlock.

As a result, the main thread remains blocked indefinitely, making this scenario even worse than using Dispatchers.IO.

The third example

Now, let’s explore another scenario. Since runBlocking blocks the current thread internally, what happens if we launch it on a worker thread? Consider the sample code below:

kt
1
2
3
4
5
6
7
8
9
10
fun sample3() = CoroutineScope(Dispatchers.IO).launch { // current thread is the I/O thread, so the runBlocking will block the I/O thread. Log.d("tag_main", "currentThread: ${Thread.currentThread()}") val result = runBlocking { Log.d("tag_main", "currentThread: ${Thread.currentThread()}") // current thread is the I/O thread delay(3000) Log.d("tag_main", "job completed") } Log.d("tag_main", "Result: $result") }

If you run the function above, you'll see the log message result below:

12:19:09.883 17946-17946 onCreate
12:19:09.907 17946-17971 currentThread: Thread[DefaultDispatcher-worker-2,5,main]
12:19:09.917 17946-17971 currentThread: Thread[DefaultDispatcher-worker-2,5,main]
12:19:12.919 17946-17971 job completed
12:19:12.919 17946-17971 Result: 24

Since the current thread executing runBlocking is switched to a worker thread by creating the coroutine scope with CoroutineScope(Dispatchers.IO), runBlocking will block only the worker thread. This means that while the execution takes 3 seconds to complete, it runs entirely on the worker thread, ensuring the main thread remains unblocked and unaffected, avoiding any UI freezing issues. In this case, blocking the main thread and freezing the UI layouts is entirely avoided.

When can you use runBlocking?

So when can you use runBlocking safely? There are two primary scenarios where its usage is appropriate: unit testing and synchronization tasks.

Writing Unit Test

One of the most common use cases for runBlocking is executing unit test code. In testing scenarios, runBlocking is frequently used to test suspending functions or coroutine-based code in a blocking manner, as shown in the example below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private fun awaitUntil(timeoutSeconds: Long, predicate: () -> Boolean) { runBlocking { val timeoutMs = timeoutSeconds * 1_000 var waited = 0L while (waited < timeoutMs) { if (predicate()) { return@runBlocking } delay(100) waited += 100 } throw AssertionError("Predicate was not fulfilled within ${timeoutMs}ms") } }

This approach is particularly useful in synchronous testing environments where the coroutine context is controlled, ensuring predictable behavior for assertions.

However, in unit testing, runTest is generally the preferred method. On JVM and Native platforms, it functions similarly to runBlocking, but with the added advantage of skipping delays within the code. This enables you to use delay without prolonging test execution time, resulting in more efficient and faster tests.

Synchronization and launch()

The second use case for runBlocking arises when you can confidently ensure that the operation will run on an I/O thread. Since runBlocking blocks the current thread until the coroutine task is complete, it can be suitable for running synchronized tasks on an I/O thread where blocking behavior is acceptable.

For instance, Stream Video SDK uses runBlocking to implement its socket re-joining feature. This is because the publisher and subscriber of the socket must be properly closed after the socket disconnection is fully completed. The SDK carefully ensures that the prepareRejoin method is executed exclusively on the I/O thread to maintain thread safety and reliability.

Alternatively, you can execute a coroutine task synchronously using the Job.join() method instead of runBlocking. Before delving into Job.join(), it’s important to first understand the behavior of the launch() method, as it returns a Job instance. Consider the example below:

kt
1
2
3
4
5
6
7
8
9
10
fun nonBlockingSample() = CoroutineScope(Dispatchers.IO).launch { // current thread is the I/O thread, so the runBlocking will block the I/O thread. Log.d("tag_main", "currentThread: ${Thread.currentThread()}") val result = launch { Log.d("tag_main", "currentThread: ${Thread.currentThread()}") // current thread is the I/O thread delay(3000) Log.d("tag_main", "end launch") } Log.d("tag_main", "job completed: $result") }
13:05:26.353  2273-2273 onCreate
13:05:26.653  2273-2964 currentThread: Thread[DefaultDispatcher-worker-1,5,main]
13:05:26.654 job completed: StandaloneCoroutine{Active}@dbc77c9
13:05:26.706  2273-2964 currentThread: Thread[DefaultDispatcher-worker-1,5,main]
13:05:29.707  2273-3367 end launch

You may have noticed that the "job completed" log message is printed earlier than the "end launch" message. This behavior is due to the nature of the launch() function. The launch() function starts a new coroutine without blocking the current thread and returns a reference to the coroutine as a Job, allowing it to execute asynchronously while the next tasks continue. But what if you want to suspend the coroutine until this job is complete?

The solution is to use the Job.join() method, which suspends the coroutine until the job is completed. This method resumes execution normally once the job finishes for any reason, provided the invoking coroutine's job is still active. You can leverage Job.join() for synchronization purposes, as demonstrated in the example below:

kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun suspending() = CoroutineScope(Dispatchers.IO).launch { // current thread is the I/O thread, so the runBlocking will block the I/O thread. Log.d("tag_main", "currentThread: ${Thread.currentThread()}") val result = launch { Log.d("tag_main", "currentThread: ${Thread.currentThread()}") // current thread is the I/O thread delay(3000) Log.d("tag_main", "end launch") } // Suspends the coroutine until this job is complete. This invocation resumes // normally (without exception) when the job is complete for any reason and the Job of the // invoking coroutine is still active. This function also starts the corresponding coroutine // if the Job was still in new state. result.join() Log.d("tag_main", "job completed: $result") }
13:21:02.246  6237-6237 onCreate
13:21:02.279  6237-6260 currentThread: Thread[DefaultDispatcher-worker-1,5,main]
13:21:02.279  6237-6262 currentThread: Thread[DefaultDispatcher-worker-3,5,main]
13:21:05.281  6237-6294 end launch
13:21:05.282  6237-6294 job completed: StandaloneCoroutine{Completed}@a5573c8

As shown in the log messages above, it took 3 seconds to print the "end launch" message, followed by the "job completed" message.

Conclusion

In this article, you’ve explored why runBlocking should be used with caution, particularly on Android. Coroutines have gained significant popularity in recent years for handling asynchronous tasks at the language level, becoming one of the most widely adopted tools. However, to use them effectively in your projects, it’s essential to understand their exact role, internal mechanisms, and proper application.

If you have any questions or feedback on this article, you can find the author on Twitter @github_skydoves or GitHub if you have any questions or feedback. If you’d like to stay up to date with Stream, follow us on Twitter @getstream_io for more great technical content.

As always, happy coding!

— Jaewoong

Ready to Increase App Engagement?
Integrate Stream’s real-time communication components today and watch your engagement rate grow overnight!
Contact Us Today!