Eliminating runblocking for di
tl;dr: How to fix runBlocking usage in Dagger providers and implement proper lazy initialization for suspension-based dependencies
When migrating Android apps to Kotlin coroutines, one common anti-pattern persists in dependency injection layers: using runBlocking to call suspend functions during DI initialization. This practice, while seemingly pragmatic, can cause main thread blocking, re-entrancy issues, and defeats the entire purpose of coroutines.
In this post, we’ll explore this problem, why it happens, and the clean architectural pattern to replace it.
The Problem: When DI Met Suspend Functions
Imagine you have a configuration provider that needs to fetch data asynchronously:
class ConfigProvider @Inject constructor(
private val remoteConfigRepo: RemoteConfigRepository,
) {
private lateinit var appConfig: AppConfig
init {
runBlocking { // ⚠️ ANTI-PATTERN: Blocks thread during initialization
appConfig = remoteConfigRepo.fetchConfig() // suspend call
// ... more suspend calls ...
}
}
fun getConfig(): AppConfig = appConfig
}
And a Dagger provider that uses this configuration to make runtime decisions:
@Provides
@Singleton
fun providesCoreService(
context: Context,
configProvider: ConfigProvider,
featureFlags: FeatureFlags,
): CoreService {
val config = configProvider.getConfig()
val service = runBlocking(Dispatchers.Unconfined) { // ⚠️ Also using runBlocking!
if (featureFlags.isNewImplementationEnabled()) {
createNewImplementation(config)
} else {
createLegacyImplementation(config)
}
}
return service
}
This pattern appears in two places:
- Class initialization - Config providers use
runBlockingininitblocks - DI providers - Dagger
@Providesfunctions userunBlockingto handle suspend creation logic
Why This Is Problematic
1. Main Thread Blocking During Startup
When Dagger builds the dependency injection graph on the main thread (common during app initialization), runBlocking will:
- Block the main thread
- Prevent the system from rendering or processing user input
- Potentially trigger ANR (Application Not Responding) if initialization takes >5 seconds
2. Defeats Coroutines Purpose
Coroutines are designed to suspend without blocking threads. Using runBlocking in a coroutines-based codebase is a fundamental architectural contradiction:
// ❌ What you're saying with runBlocking:
// "This is async, but I'm going to force it to be sync anyway"
val result = runBlocking { suspendFunction() }
// This blocks the current thread until suspendFunction completes
// Other coroutines on the same dispatcher cannot run
3. Re-entrancy and Deadlock Risks
When suspend functions call into each other through runBlocking, you can create deadlock scenarios:
// Thread A
runBlocking {
callSuspendFunctionA() // Waits here
}
// Inside suspendFunctionA (still Thread A)
val result = callSuspendFunctionB() // Also tries to run on Thread A
// If it needs resources from a different dispatcher, potential deadlock
4. Unconfined Dispatcher Pitfalls
Many implementations use Dispatchers.Unconfined:
runBlocking(Dispatchers.Unconfined) { ... }
This runs the coroutine on the current thread without any confinement guarantees, creating unpredictable threading behavior.
5. Scaling Issues
As your configuration needs grow and you have more suspend functions to call during initialization, the blocking time increases, compounding the startup performance problem.
The Root Cause: Mixing Worlds
The issue stems from a fundamental mismatch:
- Suspend functions are designed for async/await patterns
- DI providers are designed for synchronous object construction
- Configuration decisions in DI need to inspect runtime state (often async)
The naive solution is to bridge them with runBlocking, but that’s a leaky abstraction that breaks the coroutines model.
The Solution: Factory + Lazy Provider Pattern
Instead of blocking during initialization, we move the creation logic into a proper async factory and implement lazy initialization with thread-safe guarantees:
Step 1: Create a Factory Interface
interface ServiceFactory {
/**
* Suspending function to create the service.
* This delays creation logic until we're in a proper coroutine context.
*/
suspend fun create(): CoreService
/**
* Optional: Provide dependencies needed after creation.
*/
fun getAnalyticsHelper(): AnalyticsHelper
}
Step 2: Implement the Factory in DI
@Provides
@Singleton
fun providesServiceFactory(
context: Context,
httpClient: HttpClient,
configProvider: ConfigProvider,
featureFlags: FeatureFlags,
analyticsHelper: AnalyticsHelper,
): ServiceFactory {
return object : ServiceFactory {
override suspend fun create(): CoreService {
val config = configProvider.getConfig()
// Now we can make async decisions inside a suspend context
return if (featureFlags.isNewImplementationEnabled()) {
NewCoreServiceImplementation(
context = context,
httpClient = httpClient,
config = config,
)
} else {
LegacyCoreServiceImplementation(
context = context,
httpClient = httpClient,
config = config,
)
}
}
override fun getAnalyticsHelper(): AnalyticsHelper = analyticsHelper
}
}
Step 3: Create a Thread-Safe Lazy Provider
/**
* Thread-safe lazy provider for CoreService singleton.
*
* Uses mutex-based synchronization to ensure only one instance is created
* even when multiple components request it concurrently from different coroutines.
*
* This replaces the problematic runBlocking approach with proper suspension.
*/
@Singleton
class ServiceProvider @Inject constructor(
private val serviceFactory: ServiceFactory,
) {
private val mutex = Mutex()
@Volatile
private var _instance: CoreService? = null
/**
* Gets or creates the CoreService singleton.
*
* Uses double-checked locking pattern:
* - Fast path: Returns existing instance without acquiring mutex
* - Slow path: Acquires mutex, double-checks, then initializes
*/
suspend fun get(): CoreService {
// Fast path: instance already exists
_instance?.let { return it }
// Slow path: acquire lock and initialize
return mutex.withLock {
// Double-check inside the lock
_instance ?: serviceFactory.create().also { instance ->
_instance = instance
instance.addListener(serviceFactory.getAnalyticsHelper())
}
}
}
/**
* Get instance if already initialized, null otherwise.
* Safe to call from any context without suspension.
*/
fun getOrNull(): CoreService? = _instance
}
Step 4: Update Consumers
Before:
class FeatureComponent(
private val service: CoreService, // Direct dependency
) {
fun doWork() {
service.performTask(...)
}
}
After:
class FeatureComponent(
private val serviceProvider: ServiceProvider, // Lazy provider
) {
suspend fun doWork() {
serviceProvider.get().performTask(...) // Suspends until available
}
fun cleanup() {
serviceProvider.getOrNull()?.cleanup(...) // Nullable path
}
}
Key Architectural Insights
1. Double-Checked Locking with Mutex
The pattern uses a @Volatile field + Mutex:
@Volatile
private var _instance: CoreService? = null
suspend fun get(): CoreService {
_instance?.let { return it } // Fast path: no lock needed
return mutex.withLock {
_instance ?: create() // Slow path: thread-safe creation
}
}
Why this works:
- First check (outside lock) avoids unnecessary suspension on fast path
- Mutex ensures only one coroutine creates the instance
- Volatile makes the field changes visible across threads
- Second check (inside lock) handles race conditions
2. Proper Suspension vs Blocking
// ❌ Blocking: Current thread cannot run other work
runBlocking { suspendFunction() }
// ✅ Suspension: Current thread can handle other coroutines
mutex.withLock { suspendFunction() }
When a coroutine suspends, the underlying thread is freed to execute other coroutines. When a thread blocks with runBlocking, it’s stuck.
3. Deferring Configuration Decisions
The original problem: We need to decide which implementation to create based on runtime configuration, but DI providers are synchronous.
Solution: Move the decision into the factory’s suspend function:
override suspend fun create(): CoreService {
// Decision made in suspend context - no blocking!
return if (featureFlags.isNewImplementationEnabled()) {
NewImplementation(...)
} else {
LegacyImplementation(...)
}
}
Now the DI container doesn’t need to block. The decision happens the first time someone calls serviceProvider.get().
4. Nullable Access for Non-Async Contexts
Sometimes you need to access the instance from a non-coroutine context:
// In a regular function (not suspend)
fun onDestroy() {
serviceProvider.getOrNull()?.cleanup()
}
The getOrNull() returns the cached instance if it exists, or null if it hasn’t been initialized yet. This avoids forcing everything to be suspend.
Applying This Pattern Broadly
This pattern extends to any scenario where:
- Your DI provider needs to call suspend functions
- You have feature flags or runtime config that determines which implementation to use
- You want to defer expensive initialization
Common Scenarios:
- Database initialization
- Network connection setup
- Configuration fetching from remote sources
- Feature implementation selection
- Analytics or logging system setup
Best Practices
- Always prefer suspend over
runBlocking- If you’re in a Kotlin coroutines codebase, blocking is a code smell - Use
Mutexfor thread-safe lazy initialization - NotrunBlocking, not synchronized blocks - Defer expensive initialization - Move creation from DI providers to first-use
- Provide nullable access - Use
getOrNull()for non-coroutine contexts - Double-check inside locks - Prevents race conditions in concurrent scenarios
- Make the factory’s
create()suspend - This is where your async logic belongs - Document the suspension - Callers need to know when a function will suspend
Migration Checklist
If you’re fixing runBlocking in your codebase:
- Identify all
runBlockingcalls in DI providers and initialization code - Create a factory interface with a
suspend fun create()method - Move all creation logic to the factory
- Implement a lazy provider with
Mutexand double-checked locking - Update consumers to inject the provider instead of the direct service
- Update suspend callers to use
provider.get() - Add nullable paths for non-coroutine contexts with
getOrNull() - Remove
runBlockingand anyDispatchers.Unconfinedusage
Conclusion
The runBlocking anti-pattern often appears when developers try to bridge synchronous DI with asynchronous code. Instead of forcing synchronicity, embrace the async model:
- Use factories to defer creation logic
- Use lazy providers with
Mutexfor thread-safe initialization - Use suspension instead of blocking
- Use nullable paths for non-async contexts
This pattern removes thread-blocking from DI initialization, yields cleaner architecture, improves startup performance, and code that properly embraces Kotlin’s coroutine model.
By understanding when and how to apply this pattern, you’ll build more responsive Android apps and avoid the subtle threading bugs that runBlocking can introduce.