The Android ecosystem is constantly evolving, but some development pitfalls have stood the test of time. Even with years of experience, it's easy to fall back on old habits or overlook subtle issues that can impact performance, stability, and maintainability. Here are 10 mistakes we still see in the wild, even from seasoned developers.

1. Ignoring Memory Leaks in Views and Coroutines

This is the classic mistake that never dies. A memory leak occurs when an object is no longer needed but can't be garbage collected because something else holds a reference to it. In Android, this often happens with Context, Views, or long-running background tasks.

Bad: Leaky Coroutine
// In a Fragment
fun fetchData() {
    // This GlobalScope will keep running even if the Fragment is destroyed
    GlobalScope.launch {
        val data = api.fetchSomeData() // Network call
        view.showData(data) // Leaks 'view' if Fragment is gone
    }
}
Good: Using Lifecycle-Aware Scope
// In a Fragment
fun fetchData() {
    // This scope is automatically cancelled when the Fragment's view is destroyed
    viewLifecycleOwner.lifecycleScope.launch {
        val data = api.fetchSomeData()
        view.showData(data)
    }
}

2. Misusing Coroutine Scopes

Related to the first point, not all scopes are created equal. Using GlobalScope is almost always a red flag in application code. It creates a top-level coroutine that is not tied to any component's lifecycle, making it a prime source for leaks and unmanaged work.

Always use a structured, lifecycle-aware scope like viewModelScope in your ViewModel or lifecycleScope in your Fragment/Activity. This ensures that when the component is destroyed, all its associated coroutines are automatically cancelled.

Good: Using viewModelScope
class MyViewModel : ViewModel() {
    fun doWork() {
        // This work is automatically cancelled if the ViewModel is cleared.
        viewModelScope.launch {
            // ... perform long-running work
        }
    }
}

3. Passing Large Data Between Activities/Fragments

Putting large objects (like Bitmaps, complex data models, or long lists) into an Intent or Bundle is a recipe for disaster. This data is serialized to the binder transaction buffer, which has a strict limit (around 1MB). Exceeding it will cause a TransactionTooLargeException.

Instead of passing the data directly, pass only an ID or key. The receiving component can then use this ID to fetch the data from a shared ViewModel, a database (like Room), or a memory cache.

Bad: Passing a Bitmap in an Intent
val intent = Intent(this, DetailActivity::class.java)
val largeBitmap: Bitmap = // ...
intent.putExtra("image", largeBitmap) // Risk of TransactionTooLargeException
startActivity(intent)
Good: Passing an ID and using a Shared ViewModel
// In a SharedViewModel accessible by both activities
val selectedItem = MutableLiveData<Item>()

// In Activity A
sharedViewModel.selectedItem.value = itemWithLargeBitmap
val intent = Intent(this, DetailActivity::class.java)
startActivity(intent)

// In Activity B
sharedViewModel.selectedItem.observe(this) { item ->
    // Use the item safely
}

4. Neglecting UI State Restoration

Users expect an app to remember its state, especially after a configuration change (like screen rotation) or process death (when the OS kills your app in the background). Many developers test the "happy path" but forget to check what happens when the app is backgrounded and restored.

Use a ViewModel with SavedStateHandle to store simple state. For more complex data, persist it to a local database. This ensures a seamless user experience, no matter what the OS does to your process.

Good: Using SavedStateHandle in a ViewModel
class MyViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    // This will be saved and restored automatically on process death
    val searchQuery: StateFlow<String> = savedStateHandle.getStateFlow("query", "")

    fun setSearchQuery(query: String) {
        savedStateHandle["query"] = query
    }
}

5. Building Inefficient or Deeply Nested Layouts

Whether you're using XML or Jetpack Compose, a deep and complex view hierarchy is expensive. Each view requires measurement, layout, and drawing passes. With XML, deeply nested LinearLayouts were a common performance bottleneck. With Compose, excessive recomposition of large, monolithic composables can cause jank.

For XML: Favor ConstraintLayout to create flat, efficient hierarchies.
For Compose: Keep your composables small, focused, and stateless. Pass only the data they need to avoid unnecessary recompositions.

6. Blocking the Main Thread

This should be obvious, but it still happens. Any long-running operation—network requests, database queries, complex calculations, or file I/O—must not be done on the main (UI) thread. Doing so will freeze the UI and trigger an "Application Not Responding" (ANR) dialog.

Use coroutines with an appropriate dispatcher (like Dispatchers.IO for I/O or Dispatchers.Default for CPU-intensive work) to move work off the main thread.

Good: Offloading work with Dispatchers.IO
viewModelScope.launch {
    // Switch to a background thread for the network call
    val result = withContext(Dispatchers.IO) {
        api.fetchData()
    }
    // The result is now available back on the main thread to update the UI
    _uiState.value = result
}

7. Not Providing Proper Accessibility Support

Accessibility isn't an optional feature; it's a core part of building an inclusive app. Forgetting to add contentDescription to ImageViews or custom controls is a common oversight. This makes your app unusable for people relying on screen readers like TalkBack.

Bad: Inaccessible ImageView
<ImageView
    android:id="@+id/close_button"
    android:src="@drawable/ic_close" />
Good: Accessible ImageView
<ImageView
    android:id="@+id/close_button"
    android:src="@drawable/ic_close"
    android:contentDescription="@string/description_close_screen" />

8. Overusing God Objects (Massive ViewModels/Activities)

It's tempting to put all your logic into a single Activity, Fragment, or ViewModel. This quickly leads to a "God Object"—a massive, unmaintainable class that does everything. It's hard to test, difficult to debug, and nearly impossible for new team members to understand.

Break down your logic into smaller, more focused components. Use helper classes, repositories for data handling, and specific use cases (interactors) for business logic. A clean architecture pays for itself in the long run.

9. Failing to Handle Network Errors Gracefully

Your app will not always have a perfect internet connection. Many apps show a loading spinner and then... nothing. They crash or show a blank screen if the network request fails. A robust app must handle all scenarios: no internet, slow connection, server errors (5xx), or client errors (4xx).

Wrap your network calls in a try-catch block. Use a generic Resource or Result wrapper class to represent success, loading, and error states in your UI. Always provide clear feedback to the user.

Good: Handling network results
// Sealed class for UI state
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

// In ViewModel
viewModelScope.launch {
    _uiState.value = UiState.Loading
    try {
        val data = repository.getData()
        _uiState.value = UiState.Success(data)
    } catch (e: Exception) {
        _uiState.value = UiState.Error("Failed to load data.")
    }
}

10. Ignoring the Power of Jetpack DataStore

For years, SharedPreferences was the go-to for storing simple key-value data. However, it has major drawbacks: it's synchronous (can block the UI thread), not fully transactional, and offers no way to signal errors. Jetpack DataStore is the modern replacement that solves these problems.

Use Preferences DataStore for simple key-value pairs (like user settings) and Proto DataStore for typed objects. It's asynchronous, safer, and uses Kotlin Flow to notify you of data changes.

Good: Writing to Preferences DataStore
// Define a key
val THEME_KEY = stringPreferencesKey("app_theme")

// Suspending function to update the theme
suspend fun setAppTheme(context: Context, theme: String) {
    context.dataStore.edit { settings ->
        settings[THEME_KEY] = theme
    }
}