Top 10 Kotlin Coroutines Interview Questions
- Senior Android Engineer Guide
1. What are Kotlin Coroutines and how do they differ from
threads?
Answer: Kotlin Coroutines are a concurrency design pattern that allows you to write
asynchronous code in a sequential manner. They are lightweight threads that can be suspended
and resumed without blocking the underlying thread.
Key Differences:
● Weight: Coroutines are extremely lightweight - you can create thousands of them
without significant memory overhead. Threads are heavy OS-level constructs.
● Blocking: Coroutines use suspension instead of blocking. When a coroutine suspends, it
doesn't block the thread - other coroutines can use that thread.
● Memory: A coroutine typically uses only a few dozen bytes of memory, while threads
require 1-8MB of stack space.
● Context Switching: Coroutines have much faster context switching as they're managed
by the Kotlin runtime, not the OS.
Real-world example:
// Thread-based approach (blocking)
fun fetchUserData(): User {
Thread.sleep(1000) // Blocks entire thread
return User("John")
// Coroutine approach (non-blocking)
suspend fun fetchUserData(): User {
delay(1000) // Suspends coroutine, doesn't block thread
return User("John")
2. Explain the difference between launch and async
coroutine builders.
Answer: Both launch and async are coroutine builders, but they serve different purposes:
launch:
● Fire-and-forget operation
● Returns a Job object
● Used for side effects (like updating UI, logging, etc.)
● Exceptions are propagated to the parent scope immediately
async:
● Concurrent computation that returns a result
● Returns a Deferred<T> object
● Used when you need a return value
● Exceptions are held until await() is called
Example:
class UserRepository {
suspend fun loadUserProfile(userId: String) {
// launch for side effects
launch {
logUserActivity(userId)
updateLastSeen(userId)
// async for concurrent data fetching
val userInfo = async { fetchUserInfo(userId) }
val userPosts = async { fetchUserPosts(userId) }
val userFriends = async { fetchUserFriends(userId) }
// Combine results
val profile = UserProfile(
info = userInfo.await(),
posts = userPosts.await(),
friends = userFriends.await()
updateUI(profile)
3. What is a CoroutineScope and why is it important?
Answer: A CoroutineScope defines the lifecycle and context for coroutines. It's crucial for
structured concurrency - ensuring coroutines are properly managed and cancelled when no
longer needed.
Key Benefits:
● Lifecycle Management: Automatically cancels child coroutines when the scope is
cancelled
● Memory Leak Prevention: Prevents coroutines from running indefinitely
● Structured Concurrency: Provides hierarchy and organization
Android-specific scopes:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// lifecycleScope - tied to activity lifecycle
lifecycleScope.launch {
val data = fetchData()
updateUI(data)
// viewModelScope - tied to ViewModel lifecycle
viewModel.loadData()
class UserViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
try {
val users = userRepository.getUsers()
_users.value = users
} catch (e: Exception) {
_error.value = e.message
}
Custom Scope Example:
class NetworkManager {
private val networkScope = CoroutineScope(
SupervisorJob() + Dispatchers.IO +
CoroutineExceptionHandler { _, throwable ->
Log.e("NetworkManager", "Coroutine exception", throwable)
fun shutdown() {
networkScope.cancel()
4. Explain Coroutine Dispatchers and when to use each type.
Answer: Dispatchers determine which thread or thread pool a coroutine runs on. Choosing the
right dispatcher is crucial for app performance.
Main Types:
1. Dispatchers.Main
● Runs on the main UI thread
● Use for: UI updates, lightweight operations
lifecycleScope.launch(Dispatchers.Main) {
progressBar.visibility = View.VISIBLE
val result = withContext(Dispatchers.IO) { heavyOperation() }
textView.text = result
progressBar.visibility = View.GONE
2. Dispatchers.IO
● Optimized for I/O operations
● Use for: Network calls, file operations, database queries
suspend fun saveUserData(user: User) = withContext(Dispatchers.IO) {
database.userDao().insert(user)
apiService.uploadUser(user)
3. Dispatchers.Default
● CPU-intensive operations
● Use for: Heavy computations, image processing, sorting large lists
suspend fun processImage(bitmap: Bitmap) = withContext(Dispatchers.Default) {
// CPU-intensive image processing
bitmap.applyFilter()
4. Dispatchers.Unconfined
● Not confined to any specific thread
● Use sparingly: Testing or specific library implementations
Best Practices:
class ImageProcessor {
suspend fun processAndSave(imageUrl: String): String = withContext(Dispatchers.IO) {
// Download image (I/O operation)
val bitmap = downloadImage(imageUrl)
// Process image (CPU-intensive)
val processed = withContext(Dispatchers.Default) {
applyFilters(bitmap)
// Save to file (I/O operation)
val filePath = saveToFile(processed)
// Update UI (Main thread)
withContext(Dispatchers.Main) {
showSuccess("Image processed successfully")
filePath
5. How do you handle exceptions in coroutines?
Answer: Exception handling in coroutines follows structured concurrency principles with several
mechanisms:
1. Try-Catch Blocks:
suspend fun fetchUserData(): Result<User> {
return try {
val user = apiService.getUser()
Result.success(user)
} catch (e: Exception) {
Log.e("UserRepo", "Failed to fetch user", e)
Result.failure(e)
2. CoroutineExceptionHandler:
class UserViewModel : ViewModel() {
private val exceptionHandler = CoroutineExceptionHandler { _, exception ->
Log.e("UserViewModel", "Coroutine exception", exception)
_error.value = exception.message
fun loadUsers() {
viewModelScope.launch(exceptionHandler) {
val users = userRepository.getUsers()
_users.value = users
}
3. SupervisorJob for Independent Failures:
class DataSyncManager {
private val syncScope = CoroutineScope(
SupervisorJob() + Dispatchers.IO + exceptionHandler
fun syncAllData() {
syncScope.launch {
// These operations are independent - one failure won't cancel others
launch { syncUsers() }
launch { syncPosts() }
launch { syncComments() }
4. Async Exception Handling:
suspend fun loadUserProfile(): UserProfile? {
return try {
val userDeferred = async { fetchUser() }
val postsDeferred = async { fetchPosts() }
UserProfile(
user = userDeferred.await(), // Exception thrown here if fetchUser failed
posts = postsDeferred.await() // Exception thrown here if fetchPosts failed
} catch (e: Exception) {
Log.e("Profile", "Failed to load profile", e)
null
6. What is the difference between runBlocking and
coroutineScope?
Answer:
runBlocking:
● Blocks the current thread until completion
● Creates a new coroutine scope
● Primarily used in main functions, tests, and bridging blocking/non-blocking code
● Should be avoided in production Android code (can cause ANRs)
coroutineScope:
● Suspending function that doesn't block threads
● Creates a scope for concurrent operations
● Waits for all child coroutines to complete
● Preferred for concurrent operations within suspend functions
Examples:
// runBlocking - blocks thread (use in tests/main functions)
fun main() {
runBlocking {
delay(1000)
println("Hello from runBlocking")
@Test
fun testDataFetching() = runBlocking {
val result = repository.fetchData()
assertEquals(expected, result)
// coroutineScope - doesn't block (use in production)
suspend fun fetchAllUserData(userId: String): UserData = coroutineScope {
val profile = async { fetchProfile(userId) }
val friends = async { fetchFriends(userId) }
val posts = async { fetchPosts(userId) }
UserData(
profile = profile.await(),
friends = friends.await(),
posts = posts.await()
Android Production Example:
class UserRepository {
suspend fun syncUserData(userId: String) = coroutineScope {
// All operations run concurrently
val profileSync = async { syncProfile(userId) }
val settingsSync = async { syncSettings(userId) }
val preferencesSync = async { syncPreferences(userId) }
// Wait for all to complete
awaitAll(profileSync, settingsSync, preferencesSync)
7. Explain suspend functions and how suspension works
internally.
Answer: The suspend keyword marks a function as suspendable, meaning it can be paused
and resumed without blocking the thread.
How Suspension Works:
1. Continuation Passing Style (CPS): The compiler transforms suspend functions using
CPS
2. State Machine: Each suspend function becomes a state machine
3. Continuation: Represents the rest of the computation after a suspension point
Compiler Transformation Example:
// Original suspend function
suspend fun fetchUserData(): User {
val response = apiCall() // Suspension point
return parseUser(response)
}
// Simplified compiler transformation
fun fetchUserData(continuation: Continuation<User>): Any? {
when (continuation.label) {
0 -> {
continuation.label = 1
val result = apiCall(continuation)
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
// Continue to next state
1 -> {
val response = continuation.result
return parseUser(response)
Practical Example:
class UserDataLoader {
suspend fun loadUserWithPosts(userId: String): UserWithPosts {
// Suspension point 1
val user = userApi.getUser(userId)
// Suspension point 2
val posts = postsApi.getUserPosts(userId)
// Suspension point 3
val processedPosts = withContext(Dispatchers.Default) {
posts.map { processPost(it) }
return UserWithPosts(user, processedPosts)
Key Points:
● Suspend functions can only be called from other suspend functions or coroutines
● They're transformed into state machines by the compiler
● No threads are blocked during suspension
● The coroutine can be resumed on a different thread
8. What are Channels and Flows? When would you use each?
Answer:
Channels:
● Hot streams for communication between coroutines
● Similar to BlockingQueue but suspending
● One-time consumption of values
● Best for: Producer-consumer scenarios, communication between coroutines
Flows:
● Cold streams that emit values over time
● Declarative and reactive
● Each collector gets all values
● Best for: Reactive programming, observing data changes, transforming data streams
Channel Examples:
class ImageDownloader {
private val downloadChannel = Channel<String>(Channel.UNLIMITED)
fun startDownloading() {
// Producer
repeat(10) { index ->
launch {
downloadChannel.send("image_$index.jpg")
// Consumer
launch {
for (imageUrl in downloadChannel) {
processImage(imageUrl)
// Rendezvous Channel (capacity 0)
class RequestResponseHandler {
private val requestChannel = Channel<Request>()
suspend fun handleRequest(request: Request): Response {
requestChannel.send(request)
// Process and return response
return processRequest(request)
Flow Examples:
class LocationRepository {
// Cold flow - starts emitting when collected
fun getLocationUpdates(): Flow<Location> = flow {
while (true) {
val location = getCurrentLocation()
emit(location)
delay(5000) // Every 5 seconds
// StateFlow for UI state
private val _userLocation = MutableStateFlow<Location?>(null)
val userLocation: StateFlow<Location?> = _userLocation.asStateFlow()
class LocationViewModel : ViewModel() {
private val locationRepository = LocationRepository()
val locationText = locationRepository.getLocationUpdates()
.map { location ->
"Lat: ${location.latitude}, Lng: ${location.longitude}"
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = "Loading location..."
When to Use:
● Channels: Producer-consumer patterns, work queues, communication between different
parts of your app
● Flows: Observing data changes, reactive UI updates, transforming data streams,
Repository pattern
9. Explain StateFlow and SharedFlow. How do they differ
from LiveData?
Answer:
StateFlow:
● Holds and emits current state
● Always has a value
● Conflates values (only latest value matters)
● Perfect replacement for LiveData in most cases
SharedFlow:
● Can emit multiple values
● Doesn't hold state by default
● Configurable replay and buffering
● Better for events and one-time actions
Comparison with LiveData:
Feature LiveData StateFlow SharedFlow
Lifecycle Aware Yes No (but can be made No
aware)
Initial Value Optional Required None
Backpressure No Yes (conflation) Yes (configurable)
Kotlin Coroutines Limited Full support Full support
Value Conflation No Yes Configurable
Thread Safety Yes Yes Yes
Practical Examples:
class UserViewModel : ViewModel() {
// StateFlow for UI state
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// SharedFlow for one-time events
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
// StateFlow for user data
private val _userData = MutableStateFlow<User?>(null)
val userData: StateFlow<User?> = _userData.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val user = userRepository.getUser(userId)
_userData.value = user
_uiState.value = UiState.Success
_events.emit(Event.UserLoaded)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
_events.emit(Event.ErrorOccurred(e.message))
// In Activity/Fragment
class UserActivity : AppCompatActivity() {
private val viewModel: UserViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Collect StateFlow
lifecycleScope.launch {
viewModel.uiState.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> hideLoading()
is UiState.Error -> showError(state.message)
// Collect SharedFlow for events
lifecycleScope.launch {
viewModel.events.collect { event ->
when (event) {
is Event.UserLoaded -> showSuccessMessage()
is Event.ErrorOccurred -> showErrorDialog(event.message)
Migration from LiveData:
// Old LiveData approach
class OldViewModel : ViewModel() {
private val _users = MutableLiveData<List<User>>()
val users: LiveData<List<User>> = _users
fun loadUsers() {
// Load users and update LiveData
// New StateFlow approach
class NewViewModel : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
_users.value = userRepository.getUsers()
10. How do you test coroutines in Android? Explain
TestDispatchers and runTest.
Answer:
Testing coroutines requires special handling because of their asynchronous nature. Kotlin
provides several testing utilities:
Key Testing Components:
1. TestDispatchers:
● StandardTestDispatcher: Requires manual advancement
● UnconfinedTestDispatcher: Executes immediately
2. runTest:
● Replaces runBlocking for testing
● Automatically handles test dispatchers
● Provides virtual time control
Basic Testing Examples:
class UserRepositoryTest {
@Test
fun `fetchUser returns user data`() = runTest {
// Arrange
val mockApi = mockk<UserApi>()
val repository = UserRepository(mockApi)
val expectedUser = User("1", "John Doe")
coEvery { mockApi.getUser("1") } returns expectedUser
// Act
val result = repository.fetchUser("1")
// Assert
assertEquals(expectedUser, result)
coVerify { mockApi.getUser("1") }
}
@Test
fun `fetchUser handles network error`() = runTest {
// Arrange
val mockApi = mockk<UserApi>()
val repository = UserRepository(mockApi)
coEvery { mockApi.getUser("1") } throws NetworkException("Network error")
// Act & Assert
assertThrows<NetworkException> {
repository.fetchUser("1")
Testing ViewModels:
class UserViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
@After
fun tearDown() {
Dispatchers.resetMain()
@Test
fun `loadUser updates uiState correctly`() = runTest {
// Arrange
val mockRepository = mockk<UserRepository>()
val viewModel = UserViewModel(mockRepository)
val user = User("1", "John")
coEvery { mockRepository.getUser("1") } returns user
// Act
viewModel.loadUser("1")
// Advance virtual time to let coroutines complete
advanceUntilIdle()
// Assert
assertEquals(UiState.Success, viewModel.uiState.value)
assertEquals(user, viewModel.userData.value)
@Test
fun `loadUser handles repository error`() = runTest {
// Arrange
val mockRepository = mockk<UserRepository>()
val viewModel = UserViewModel(mockRepository)
val errorMessage = "Network error"
coEvery { mockRepository.getUser("1") } throws Exception(errorMessage)
// Act
viewModel.loadUser("1")
advanceUntilIdle()
// Assert
assertTrue(viewModel.uiState.value is UiState.Error)
assertEquals(errorMessage, (viewModel.uiState.value as UiState.Error).message)
Testing Flows:
class LocationRepositoryTest {
@Test
fun `getLocationUpdates emits location data`() = runTest {
// Arrange
val mockLocationProvider = mockk<LocationProvider>()
val repository = LocationRepository(mockLocationProvider)
val locations = listOf(
Location(40.7128, -74.0060),
Location(40.7589, -73.9851)
every { mockLocationProvider.getCurrentLocation() } returnsMany locations
// Act & Assert
repository.getLocationUpdates()
.take(2)
.toList()
.also { emittedLocations ->
assertEquals(locations, emittedLocations)
Testing with Turbine (Popular Testing Library):
@Test
fun `userState flow emits correct values`() = runTest {
val repository = UserRepository()
repository.userState.test {
// Initial state
assertEquals(UserState.Loading, awaitItem())
// Trigger data load
repository.loadUser("123")
// Verify success state
assertEquals(UserState.Success(expectedUser), awaitItem())
// Verify no more emissions
expectNoEvents()
Best Practices for Testing Coroutines:
1. Use runTest instead of runBlocking
2. Use TestDispatchers for controlled execution
3. Use advanceUntilIdle() to complete all pending coroutines
4. Mock suspend functions with coEvery and coVerify
5. Test both success and error scenarios
6. Use turbine library for Flow testing
7. Always test cancellation scenarios for long-running operations
Summary
These interview questions cover the essential aspects of Kotlin Coroutines that every senior
Android engineer should understand:
1. Fundamentals: Understanding what coroutines are and their advantages
2. Builders: Knowing when to use launch vs async
3. Scopes: Proper lifecycle management and structured concurrency
4. Dispatchers: Thread management and performance optimization
5. Error Handling: Robust exception handling strategies
6. Suspension: Understanding how the suspension mechanism works
7. Communication: Channels vs Flows for different use cases
8. State Management: Modern reactive programming with StateFlow/SharedFlow
9. Testing: Proper testing strategies for asynchronous code
Mastering these concepts will enable you to write efficient, maintainable, and robust Android
applications using Kotlin Coroutines.