Building a Scalable Architecture for an eCommerce App with Jetpack Compose

Design a scalable architecture for an eCommerce app built with Jetpack Compose. This architecture will support key features like offline product caching, real-time inventory updates, paginated product listings, and modular UI with feature separation. We’ll focus on best practices for scalability, maintainability, and modularity, ensuring the app can handle future growth efficiently.

Overview of the App Architecture

The architecture for this app will be based on Clean Architecture, separating concerns into Presentation, Domain, and Data layers. We will also modularize the app to ensure flexibility, and each feature (e.g., Product, Cart, Inventory) will be handled in a separate module.

We'll incorporate Jetpack Compose for UI, Room for offline caching, Paging 3 for efficient product listing, and Firebase/Realtime Database or WebSocket for real-time inventory updates.

Layered Architecture Breakdown

1. Presentation Layer (UI)

The Presentation Layer is responsible for the user interface and user interactions. With Jetpack Compose, we can easily build reactive and dynamic UIs. The UI will be composed of Composables, while ViewModels will handle the UI state and interact with the Domain layer.

Key Components:

  • Jetpack Compose: For building the user interface in a declarative way.

  • ViewModel: Handles state management and communicates with the Domain layer.

  • StateFlow/LiveData: For managing UI state like loading, success, and error states.

  • Navigation: Jetpack Navigation Compose to manage the app's navigation.

Example Composables:

@Composable
fun ProductListScreen(viewModel: ProductListViewModel) {
    val products by viewModel.products.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val isError by viewModel.isError.collectAsState()

    if (isLoading) {
        CircularProgressIndicator()
    } else if (isError) {
        Text("Error fetching products")
    } else {
        LazyColumn {
            items(products) { product ->
                ProductItem(product = product)
            }
        }
    }
}

2. Domain Layer

The Domain Layer holds the business logic and use cases. This layer abstracts the data layer and provides clean interfaces for the Presentation layer to interact with. The domain layer consists of Use Cases and Repository interfaces.

Key Components:

  • Use Cases: Define business logic, such as fetching products, pagination, and handling inventory.

  • Repositories: Interface that defines data-fetching operations like fetching products, updating inventory, and more.

Example Use Case:

class GetProductListUseCase(private val productRepository: ProductRepository) {
    suspend operator fun invoke(page: Int): Result<List<Product>> {
        return productRepository.getPaginatedProducts(page)
    }
}

3. Data Layer

The Data Layer handles data fetching, caching, and the communication with external services (like APIs and Firebase). This layer includes repositories for both remote data (API calls) and local data (Room Database). We’ll use Room for offline caching and Paging 3 for efficient data loading.

Key Components:

  • Room: Used for offline caching of products and inventory data.

  • API Services: Retrofit or Ktor for interacting with remote APIs for products and real-time updates.

  • Firebase/Realtime Database: Used for real-time inventory updates.

  • Paging 3: Efficiently handles pagination for product lists.

Offline Caching Example with Room:

@Entity(tableName = "product")
data class ProductEntity(
    @PrimaryKey val id: Int,
    val name: String,
    val price: Double,
    val stockQuantity: Int
)

@Dao
interface ProductDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertProducts(products: List<ProductEntity>)

    @Query("SELECT * FROM product")
    suspend fun getAllProducts(): List<ProductEntity>
}

Repository Example:

class ProductRepositoryImpl(
    private val apiService: ApiService,
    private val productDao: ProductDao
) : ProductRepository {

    override suspend fun getPaginatedProducts(page: Int): Result<List<Product>> {
        val productsFromCache = productDao.getAllProducts()
        if (productsFromCache.isNotEmpty()) {
            return Result.success(productsFromCache.map { it.toDomain() })
        }

        try {
            val response = apiService.getProducts(page)
            productDao.insertProducts(response.products.map { it.toEntity() })
            return Result.success(response.products.map { it.toDomain() })
        } catch (e: Exception) {
            return Result.failure(e)
        }
    }
}

4. Real-Time Inventory Updates

For real-time inventory updates, we can use Firebase Realtime Database or WebSocket. When the stock quantity of a product changes, the app will update the product's data in real time, and the UI will reflect the updated information.

Firebase Example:

class FirebaseInventoryRepository {
    private val database = FirebaseDatabase.getInstance().getReference("inventory")

    fun observeInventoryUpdates(productId: Int, callback: (Int) -> Unit) {
        database.child("products").child(productId.toString()).child("stockQuantity")
            .addValueEventListener(object : ValueEventListener {
                override fun onDataChange(snapshot: DataSnapshot) {
                    val stockQuantity = snapshot.getValue(Int::class.java) ?: 0
                    callback(stockQuantity)
                }

                override fun onCancelled(error: DatabaseError) {
                    // Handle error
                }
            })
    }
}

5. Modularization

To ensure that the app remains maintainable as it grows, we will modularize the codebase. Each feature, such as the Product module, Cart module, and Inventory module, will be developed in separate modules.

This separation ensures that each module is responsible for one feature and can be developed and tested independently. It also improves build times and allows for easier team collaboration.

Modularization Example:

// In build.gradle for 'product' module
dependencies {
    implementation project(":core")
    implementation "androidx.compose.ui:ui:$compose_version"
}

6. Offline Handling and Connectivity

The app should handle offline scenarios gracefully, providing users with cached data when they are not connected to the internet. We can use the ConnectivityManager to check the network status and display cached products when offline. When the network is available, the app should fetch real-time data.

Offline Strategy:

  • Room Database: Cache products and inventory locally.

  • Network Status: Use ConnectivityManager to determine if the app is online or offline.

7. Real-Time Sync with Firebase

Firebase can be used for real-time syncing of inventory data. Using Firebase Realtime Database, the app can listen for changes to inventory quantities and update the UI instantly. Alternatively, WebSocket can be used to get real-time updates from the backend.

My thoughts

This architecture leverages modern Android tools like Jetpack Compose, Room, Paging 3, Firebase, and Clean Architecture to build a scalable and maintainable eCommerce app. The use of modularization ensures that each feature is self-contained, while the domain-driven design keeps the business logic separated from the UI.

By incorporating offline caching, real-time updates, and pagination, this architecture provides a robust foundation for building a seamless, scalable eCommerce experience that performs well even in scenarios with slow or no network connectivity.

πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! Please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»

Shipping High-Quality Android Code with Testing in Iterative Cycles (Kotlin + Jetpack Compose)

In modern Android development, building robust apps goes far beyond just writing code. It’s about delivering high-quality features in iterative cycles with the confidence that everything works as expected. This article walks you through a full feature development cycle in Android using Kotlin, Jetpack Compose, and modern testing practices.

We’ll build a simple feature: a button that fetches and displays user data. You'll learn how to:

  • Structure code using ViewModel and Repository

  • Write unit and UI tests

  • Integrate automated testing in CI/CD pipelines

Let’s dive in 

Full Cycle with Testing - Detailed Steps


Step 1: Set up your environment and dependencies

Before starting, ensure your project has the following dependencies in build.gradle (Module-level):

dependencies {
    implementation "androidx.compose.ui:ui:1.3.0"
    implementation "androidx.compose.material3:material3:1.0.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
    testImplementation "junit:junit:4.13.2"
    testImplementation "org.mockito:mockito-core:4.0.0"
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.3.0"
    androidTestImplementation "androidx.test.ext:junit:1.1.5"
    androidTestImplementation "androidx.test.espresso:espresso-core:3.5.0"
}

Step 2: Implement the Feature

Let’s assume the feature involves a button that, when clicked, fetches user data from an API.

2.1: Create the User Data Model

data class User(val id: Int, val name: String)

2.2: Create the UserRepository to Fetch Data

class UserRepository {
    // Simulate network request with delay
    suspend fun fetchUserData(): User {
        delay(1000)  // Simulating network delay
        return User(id = 1, name = "John Doe")
    }
}

2.3: Create the UserViewModel to Expose Data

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    private val _userData = MutableLiveData<User?>()
    val userData: LiveData<User?> get() = _userData

    fun fetchUser() {
        viewModelScope.launch {
            _userData.value = repository.fetchUserData()
        }
    }
}

2.4: Create the UserScreen Composable to Display Data

@Composable
fun UserScreen(viewModel: UserViewModel) {
    val user by viewModel.userData.observeAsState()
    Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
        Button(onClick = { viewModel.fetchUser() }) {
            Text("Fetch User")
        }
        user?.let {
            Text(text = "User: ${it.name}")
        } ?: Text(text = "No user fetched yet")
    }
}

Step 3: Write Unit Tests

3.1: Unit Test for UserRepository

Write a simple unit test to test that UserRepository correctly fetches user data.

@RunWith(MockitoJUnitRunner::class)
class UserRepositoryTest {
    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        repository = UserRepository()
    }

    @Test
    fun testFetchUserData() = runBlocking {
        val result = repository.fetchUserData()
        assertEquals(1, result.id)
        assertEquals("John Doe", result.name)
    }
}

3.2: Unit Test for UserViewModel

Next, test that the UserViewModel correctly interacts with the repository and updates the UI state.

class UserViewModelTest {
    private lateinit var viewModel: UserViewModel
    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        repository = mock(UserRepository::class.java)
        viewModel = UserViewModel(repository)
    }

    @Test
    fun testFetchUser() = runBlocking {
        val user = User(1, "John Doe")
        `when`(repository.fetchUserData()).thenReturn(user)

        viewModel.fetchUser()

        val value = viewModel.userData.getOrAwaitValue()  // Use LiveData testing extensions
        assertEquals(user, value)
    }
}

Step 4: Write UI Tests with Jetpack Compose

4.1: Write Compose UI Test

Use ComposeTestRule to test the UserScreen composable.

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testFetchUserButton_click() {
    val viewModel = UserViewModel(UserRepository())

    composeTestRule.setContent {
        UserScreen(viewModel)
    }

    // Assert that the UI initially shows the "No user fetched yet" text
    composeTestRule.onNodeWithText("No user fetched yet").assertIsDisplayed()

    // Simulate button click
    composeTestRule.onNodeWithText("Fetch User").performClick()

    // After clicking, assert that "User: John Doe" is displayed
    composeTestRule.onNodeWithText("User: John Doe").assertIsDisplayed()
}

4.2: Write Espresso Test for Hybrid UI (if needed)

In case you’re testing a hybrid UI (Jetpack Compose + XML Views), you can also use Espresso.

@Test
fun testFetchUserButton_click() {
    onView(withId(R.id.fetchUserButton)).perform(click())
    onView(withId(R.id.resultText)).check(matches(withText("User: John Doe")))
}

Step 5: Run All Tests in CI/CD

  • Set up GitHub Actions or Jenkins to automatically run the tests on every push to the repository.

Here’s an example configuration for GitHub Actions:

name: Android CI

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up JDK
        uses: actions/setup-java@v2
        with:
          java-version: '11'

      - name: Build and test
        run: ./gradlew build testDebugUnitTest connectedDebugAndroidTest

Step 6: Code Review and Refactor

With your code and tests in place:

  •  Refactor for better architecture

  •  Add new states like loading and error

  •  Add tests for edge cases

  •  Merge changes with confidence

  •  Release to staging/production


Step 7: Deploy to Staging or Production

After successful tests, deploy the feature to a staging environment or directly to production, depending on your CI/CD pipeline.


Key Takeaways:

  1. Iterative development: Implement small, testable features and enhance them with every iteration.

  2. Automate testing: Unit tests, UI tests, and integration tests should be automated and run on every code change.

  3. Focus on user behavior: Write tests that mimic real user behavior (e.g., pressing buttons and verifying UI changes).

  4. Continuous integration: Set up CI/CD pipelines to run tests automatically and ensure the stability of your app at all times.

By following this cycle, you ensure that the app is always in a deployable state, with tests proving that each feature works as expected.

Final Thoughts

Iterative development backed by strong testing practices ensures you can deliver robust Android features without fear of breaking the app. With tools like Jetpack Compose, JUnit, and CI/CD, it’s never been easier to ship confidently.

πŸ’‘ “The best code is not only written, it's verified and trusted by tests.”



πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»✨

Flutter in 2025: For Mobile Developer

Flutter remains one of the most powerful cross-platform frameworks for building stunning, natively compiled apps across mobile, web, desktop, and embedded platforms — all from a single codebase.

As we enter 2025, the Flutter ecosystem has evolved with better tooling, modern language features in Dart 3.x, and strong support for WebAssembly and AI-integrated development. But to build truly scalable, secure, and performant apps, developers need to stay current with key foundational areas.

Here’s a breakdown of the 10 core areas you must master as a Flutter mobile developer in 2025.


1. Dart 3.2+ and Async Programming

With Dart 3.2, Flutter development is more powerful than ever.

πŸ“Œ Key Concepts to Learn:

  • Null safety (default since Dart 3)

  • Records, pattern matching, sealed classes

  • Async programming with Future, Stream, await

  • Extensions, mixins, and functional style

2025 Update: Dart now supports Wasm compilation and advanced concurrency with isolates and async/await, making understanding async design patterns critical for app responsiveness.


2.  Declarative UI with Widgets & Material 3

UI in Flutter is declarative and composable, which means your app's layout is just a function of its current state.

πŸ“Œ Focus Areas:

  • Stateless vs Stateful widgets

  • Composable widget trees

  • Layouts: Flex, Column, Stack, Wrap

  • Material 3 (Material You) adoption with adaptive themes and dynamic color

2025 Update: Flutter now natively supports Material 3 Expressive Design and Theme Extensions, which allow you to easily scale design systems across platforms with Figma-to-code tools.


3. Scalable State Management

State management continues to be a crucial part of Flutter development.

πŸ“Œ Popular Choices:

  • Riverpod 3.0+ (robust, testable, compile-safe)

  • Bloc/Cubit (great for enterprise)

  • GetX and Signals for lightweight reactivity

  • flutter_hooks for cleaner widget lifecycle

2025 Update: Riverpod with code generation and async_notifier support enables highly efficient reactive apps. If you're building for scale, clean architecture + Riverpod is the trend.


4. Networking, GraphQL & API Integration

Data-driven apps are the norm, and Flutter excels with powerful tools.

πŸ“Œ Libraries to Know:

  • Dio or http for REST

  • GraphQL Flutter for GraphQL APIs

  • freezed + json_serializable for code generation

  • Retry strategies, interceptors, and API versioning

2025 Update: GraphQL is rising in popularity for enterprise apps. Combine it with caching strategies and network status awareness (connectivity_plus) for resilience.


5. Offline Storage & Data Persistence

Modern apps require data to be persistent and accessible offline.

πŸ“Œ Solutions to Learn:

  • Hive or Isar for local NoSQL storage

  • Drift for reactive SQLite

  • SharedPreferences for simple key-value storage

  • Secure Storage for encrypted secrets

2025 Update: Use Isar for Flutter + Web support and blazing performance. Drift remains top-tier for structured queries with migration support.


6. Platform Integration & Native Features

Sometimes, you’ll need access to native APIs like sensors, camera, or AR.

πŸ“Œ How To:

  • Use MethodChannel or PlatformChannel

  • Use pigeon for compile-safe bindings

  • Leverage platform-specific plugins

2025 Update: Flutter now supports better platform integration using ffigen and wasm_interop. For advanced native support, stay updated with platform-specific SDKs.


7. Authentication & Security

Securing user identity and data is non-negotiable in 2025.

πŸ“Œ Essential Tools:

  • firebase_auth, supabase, or auth0

  • OAuth2 with flutter_appauth

  • flutter_secure_storage for encrypted local secrets

  • Biometric authentication via local_auth

2025 Update: Biometric + passkey support is trending. Flutter plugins now support FIDO2, passwordless auth, and platform-specific secure sign-ins.


8. Testing: From Unit to Integration

Testing ensures your app remains stable as it grows.

πŸ“Œ Types of Tests:

  • Unit Tests – business logic

  • Widget Tests – UI components

  • Integration Tests – app flows

  • Use: flutter_test, mocktail, integration_test, golden_toolkit

2025 Update: Integrate golden tests for visual regressions and use Flutter Coverage tools in CI for better insights.


9. CI/CD, Build, and Deployment

Release confidently with automation.

πŸ“Œ Key Tools:

  • Codemagic, GitHub Actions, Bitrise

  • Versioning, flavoring, code signing

  • flutter_launcher_icons, flutter_native_splash automation

  • A/B testing with Firebase Remote Config

2025 Update: Codemagic now supports WASM builds and multi-platform pipelines, ideal for Flutter Web + Mobile apps.


10.  Performance Optimization

Users expect fluid, 60+ FPS apps with minimal lag.

πŸ“Œ Best Practices:

  • Use const widgets and RepaintBoundary

  • Efficient lists: ListView.builder, SliverList

  • Monitor with Flutter DevTools, leak_tracker, performance_overlay

2025 Update: Use impeller (Flutter's next-gen rendering engine) for buttery-smooth animations, especially on Android.


 Summary Table

 Area  2025 Focus
Dart 3.x & Async Records, pattern matching, isolates, await
Widget UI & Material 3 Adaptive theming, Figma token export, animation APIs
State Management Riverpod, clean architecture, Signals
API/GraphQL Integration Dio, Retrofit, GraphQL, resilient networking
Local & Secure Storage Hive, Drift, Isar, secure local auth data
Platform Channels Pigeon, Wasm interop, native extensions
Authentication & Security OAuth2, biometric/passkey auth, FIDO2
Testing & Automation Unit + Widget + Golden tests, visual diff, Mocktail
CI/CD & Deployment GitHub Actions, Codemagic pipelines, multi-flavor builds
Performance & DevTools Impeller engine, DevTools, performance tracing

 Final Thoughts

Flutter in 2025 is more powerful than ever. With Material 3 integration, performance improvements via Impeller, better testing tools, and rich platform access, it’s the ideal choice for scalable mobile development.

But great apps aren’t just built — they’re engineered. Master these 10 areas and you'll be fully equipped to build production-grade, cross-platform apps that delight users and scale gracefully.


πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»✨

How to Fix Android App Bugs Without Breaking Everything Else

When fixing bugs in an Android app project, it's crucial to not just patch the issue, but to ensure stability, performance, and maintainability of the app. Here's a structured list of key areas to address during bug fixing:


 1. Reproduce and Isolate the Bug

  • Reproduce the issue consistently in a local or QA environment.

  • Logcat analysis: Use Log.d, Log.e, Timber, etc., to check stack traces and contextual logs.

  • Crash reports: Utilize tools like Firebase Crashlytics, Sentry, or Bugsnag to identify the root cause.

  • Debug tools: Use Android Studio debugger, breakpoints, or Flipper for runtime inspection.


 2. Identify Root Cause (Not Just Symptoms)

  • Avoid surface-level fixes that mask the issue.

  • Trace through stack traces, thread state, and lifecycle behavior.

  • Use techniques like binary search debugging, rollback comparison, or git bisect.


 3. Check Affected Scope and Dependencies

  • Understand the scope: What modules/features are impacted?

  • Review dependencies (third-party libraries, APIs, database, sensors, etc.).

  • Check for side effects or hidden regressions in related components.


4. Fix Using Best Practices

  • Apply Kotlin null safety, immutability, lifecycle awareness, and coroutine exception handling.

  • Respect architecture (e.g., Clean Architecture, MVVM).

  • Avoid memory leaks (e.g., by cleaning up observers or references in onCleared() or onDestroy()).


5. Test the Fix Thoroughly

  • Unit test the logic.

  • UI/Instrumentation tests (e.g., using Espresso).

  • Edge cases: test on different devices, orientations, languages, and API levels.

  • Use mock data and real-world scenarios.

  • Automate via CI/CD if possible.


6. Code Review & PR Standards

  • Push changes to a feature or hotfix branch.

  • Follow team PR templates/checklists.

  • Include before/after behavior screenshots or videos (especially for UI bugs).

  • Peer review for readability, performance, and maintainability.


7. Update Logs, Metrics, and Documentation

  • Update CHANGELOG.md or release notes.

  • Add JIRA/Trello references and resolution notes.

  • If it's a backend-related issue, sync with backend devs.

  • If API contract changes, update API schema or versioning.


8. Deploy Carefully

  • Use staged rollout (e.g., Play Console’s percentage rollout).

  • Monitor logs and crash analytics post-deploy.

  • Add feature flags if needed for emergency disable.

9. Retrospective or Root Cause Analysis (RCA)

  • Log the root cause and learnings in a shared document.

  • Improve test coverage or monitoring based on the gap.

  • Share insights with the team in sprint retro or knowledge base.


Tools That Help:

Category Tool
Logging Timber, Logcat, Crashlytics
Debugging Android Studio, Flipper, LeakCanary
Monitoring Firebase Performance, Sentry
Testing JUnit, Espresso, MockK, Robolectric
CI/CD GitHub Actions, Bitrise, Jenkins
Issue Tracking Jira, Trello, Linear

Here’s a realistic example of a bug fix in an Android app, including the bug description, root cause, fix implementation, and testing strategy.


Example : App Crashes on Orientation Change While Loading Data


Bug Report

  • Title: App crashes when rotating screen during data loading on ProfileFragment.

  • Severity: High (crashes app)

  • Environment: Android 12, Pixel 5, API 31

  • Steps to Reproduce:

    1. Open the app and navigate to Profile tab.

    2. While the data is loading (loading spinner showing), rotate the screen.

    3. App crashes with IllegalStateException.


Crash Log

java.lang.IllegalStateException: ViewModel not attached to lifecycle yet
    at ProfileViewModel.getProfileData(ProfileViewModel.kt:45)

Root Cause

  • The ProfileFragment was trying to observe LiveData before the view was fully recreated after orientation change.

  • The data load was tied to the fragment’s onCreateView rather than viewLifecycleOwner.


Fix

Refactor LiveData observer to bind with viewLifecycleOwner instead of the fragment's lifecycle.

// Before (incorrect)
viewModel.profileLiveData.observe(this, Observer {
    // update UI
})

// After (correct)
viewModel.profileLiveData.observe(viewLifecycleOwner, Observer {
    // update UI
})

Additionally, use repeatOnLifecycle for Kotlin Flow in Compose or ViewModel coroutine collection:

lifecycleScope.launch {
    viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.profileFlow.collect {
            // Update UI
        }
    }
}

Testing Done

  • Rotated device multiple times during and after data load.

  • Verified no crashes on API 30, 31, 34.

  • Verified ViewModel data not lost.

  • Espresso UI test added for rotation.


PR Notes

πŸ“Œ Fixed crash on orientation change in ProfileFragment by using viewLifecycleOwner for LiveData observer. Added test for configuration change handling.
JIRA Ticket: APP-2931
Impact: Profile screen lifecycle handling.


Improvement

Added lifecycle-aware logging to catch future orientation-related issues:

Log.d("Lifecycle", "ProfileFragment state: ${lifecycle.currentState}")

πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»✨

Common Scenarios & Examples of Coroutine Memory Leaks

 What is a Memory Leak?

A memory leak occurs when memory that is no longer needed is not released because the program still holds references to it. In Android, this means the garbage collector (GC) cannot reclaim that memory, potentially leading to OutOfMemoryError and app crashes.


 How Do Memory Leaks Occur in a Coroutine?

Coroutines are lightweight threads, but if not scoped and managed correctly, they can continue running even when the component (like an Activity or ViewModel) is destroyed. This can lead to memory leaks.





 Common Scenarios & Examples of Coroutine Memory Leaks

Here are some common scenarios where memory leaks can happen with coroutines, what causes them, what happens afterward, and how to fix them:


1. Coroutine Scope Tied to a Destroyed Component

❌ Problem:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycleScope.launch {
            delay(10_000) // 10-second delay
            Log.d("Coroutine", "Finished!")
        }
    }
}

 What Happens:

Even if the user rotates the screen or navigates away, the coroutine keeps running unless cancelled. If it references this, the Activity is retained in memory.

✅ Fix:

Use a lifecycle-aware scope, like lifecycleScope, and ensure the task is cancellable:

lifecycleScope.launch {
    withTimeoutOrNull(5000) {
        delay(10_000)
    }
}

2. GlobalScope Misuse

❌ Problem:

GlobalScope.launch {
    val bitmap = loadLargeBitmap()
    imageView.setImageBitmap(bitmap)
}

 What Happens:

  • GlobalScope lives for the entire app lifecycle.

  • If imageView belongs to a destroyed Activity, it is retained in memory.

✅ Fix:

Use viewModelScope, lifecycleScope, or a custom CoroutineScope that is cancelled appropriately:

class MyViewModel : ViewModel() {
    fun loadImage() {
        viewModelScope.launch {
            val bitmap = loadLargeBitmap()
            _bitmapLiveData.value = bitmap
        }
    }
}

3. ViewModel or Activity Holds Long-lived Coroutine with UI Reference

❌ Problem:

class MyViewModel : ViewModel() {
    var activity: MainActivity? = null

    fun doWork() {
        viewModelScope.launch {
            delay(10_000)
            activity?.updateUI() // Leaks MainActivity
        }
    }
}

 What Happens:

Even after MainActivity is destroyed, the coroutine keeps a reference to it via activity.

✅ Fix:

Avoid passing UI references. Use LiveData, StateFlow, or a callback interface that is weakly referenced.


4. Job not Cancelled in onDestroy()

❌ Problem:

class MyActivity : AppCompatActivity() {
    private val job = Job()
    private val scope = CoroutineScope(Dispatchers.Main + job)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        scope.launch {
            fetchData()
        }
    }

    //  Missing job.cancel()
}

 What Happens:

The coroutine continues running even after the Activity is destroyed.

✅ Fix:

Cancel the job:

override fun onDestroy() {
    super.onDestroy()
    job.cancel()
}

5. Flow Collection Without Proper Scope

❌ Problem:

viewModel.dataFlow.onEach {
    textView.text = it
}.launchIn(GlobalScope) //  WRONG

 What Happens:

This will outlive the lifecycle of the view that textView belongs to.

✅ Fix:

viewModel.dataFlow
    .onEach { textView.text = it }
    .launchIn(lifecycleScope)

Or use:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.dataFlow.collect {
            textView.text = it
        }
    }
}

 Best Practices to Avoid Coroutine Memory Leaks

 Practice  Explanation
Use viewModelScope, lifecycleScope These scopes are lifecycle-aware and cancel automatically.
Avoid GlobalScope for UI tasks GlobalScope never cancels, leading to leaks.
Always cancel custom scopes Manually call job.cancel() in onDestroy() or similar.
Avoid long-lived references to Context/UI Don’t store Activities/Views inside ViewModels or background coroutines.
Use structured concurrency Always launch coroutines inside a well-defined scope.
Make coroutines cancellable Use withTimeout, isActive, and ensure long-running tasks honor cancellation.
Use repeatOnLifecycle for Flow Ensures collection only happens during active lifecycle state.

 Use Case Example: Fetch User Profile and Update UI

❌ Incorrect (Leaky)

class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        GlobalScope.launch {
            val user = fetchUserProfile()
            runOnUiThread {
                profileTextView.text = user.name
            }
        }
    }
}

✅ Correct (Leak-safe)

class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            val user = fetchUserProfile()
            profileTextView.text = user.name
        }
    }
}

 Summary

πŸ›‘ Don't Do This ✅ Do This Instead
Launch coroutine in GlobalScope Use viewModelScope or lifecycleScope
Hold references to Activity/View Use LiveData/StateFlow for updates
Ignore coroutine cancellation Make coroutine cancellable
Launch coroutine in onCreate w/o guard Use repeatOnLifecycle or cancel scope

πŸ“’ Feedback: Did you find this article helpful? Let me know your thoughts or suggestions for improvements! 😊 please leave a comment below. I’d love to hear from you! πŸ‘‡

Happy coding! πŸ’»✨