Jetpack Compose has transformed Android UI development. Its declarative programming model, concise syntax, and reactive state integration make building modern interfaces a joy.
Until you load a few hundred date-grouped financial transactions into a LazyColumn, swipe down rapidly, and watch your silky-smooth 120Hz display stutter.
In personal finance apps, transaction feeds are dynamic, high-density lists. Each row contains merchant labels, amount badges with currency formatting, category icons, account chips, and date headers.
If your reactive state pipeline is poorly architected, your UI thread will spend its 8-millisecond frame budget performing heavy list sorting and string formatting rather than rendering pixels.
Here is how FinLedger diagnosed and eliminated UI frame drops across its transaction ledger, achieving consistent 60/120fps scrolling.
Diagnosing the Stutter: Where Do Dropped Frames Come From?
When a screen drops frames (known as "UI jank"), it means the Android main thread (UI thread) took longer than 16.6ms (for 60Hz displays) or 8.3ms (for 120Hz displays) to prepare and draw a frame.
In our early Compose implementation, profiling revealed three distinct architectural bottlenecks:
flowchart TD
subgraph Bottlenecks["The Three Root Causes of Compose UI Jank"]
direction TB
B1["1. Main-Thread Flow Transformations:
Grouping and sorting 500 transactions inside the ViewModel pipeline on Dispatchers.Main"]
B2["2. Database Observer Churn:
8 independent Room Flow queries firing simultaneously, causing cascade recompositions"]
B3["3. Unstable Lambda and Model Classes:
Compose Compiler treating domain models as unstable, forcing unnecessary recompositions"]
end
1. Heavy List Transformations on the Main Thread
In our initial implementation, the LedgerViewModel combined transactions, accounts, and filter states. Inside the .map { ... } operator, it grouped transactions by date string (LocalDate), formatted currency strings, and calculated daily net sums. Because the collector was attached to the UI lifecycle, this heavy list mapping ran directly on Dispatchers.Main.
Every time a user typed a character in the search box, the entire list was regrouped and sorted on the main thread, choking the Choreographer.
2. Multi-Table Flow Collision
The ledger screen needs accounts, transactions, categories, and tags. Having the screen observe 8 separate Room Flow queries caused 8 independent emission cascades whenever an underlying database transaction committed. The UI was recomposing 5 to 8 times within a single 50ms window.
3. Compose Stability Penalties
The Compose compiler skips recomposing composables whose parameters are verified as @Immutable or @Stable. If a composable receives standard Kotlin collections (List<Transaction>) or classes defined in non-Compose modules, the compiler treats them as unstable, forcing every single row in the lazy column to recompose during any parent state change.
The Engineering Solutions: Hardening Sprint H6
During FinLedger's performance hardening sprints (commit f7a181a and Sprint H6), we executed four systematic refactorings:
flowchart LR
subgraph OptimizedPipeline["FinLedger Optimized Reactive Pipeline"]
direction TB
DB[("Room SQLite")] -->|"1. Merged Hot Streams"| SharedFlow["Shared Hot Flow (3 Pipelines)"]
SharedFlow -->|"2. Offloaded to Dispatchers.Default"| WorkerThread["Background Compute:
Sort, Group by Date, Compute Daily Deltas"]
WorkerThread -->|"3. Stable UI State"| UiState["Immutable LedgerUiState"]
UiState -->|"4. collectAsStateWithLifecycle"| ComposeUI["Compose LazyColumn
(Zero Main-Thread Work)"]
end
1. Offloading Flow Mappings to Dispatchers.Default
We refactored the reactive data pipeline in LedgerViewModel to strictly offload all list partitioning, sorting, and summary math to background threads:
- Database flows are combined using Kotlin's
combineoperator. - All filtering, date-grouping, and formatting transformations are wrapped in
.flowOn(Dispatchers.Default). - The main thread receives a pre-computed, fully partitioned, immutable
LedgerUiStateready for immediate rendering. The UI thread does zero collection manipulation.
2. Debouncing Real-Time Search Queries
When searching through thousands of records, firing a database query or list filter on every single keystroke causes rapid flow emissions. We introduced a 200ms debounce on search queries (_searchQuery.debounce(200)), allowing users to type fluidly without triggering pipeline thrashing until their finger pauses.
3. Consolidating 8 Flow Queries into 3 Shared Hot Streams
Instead of having screens subscribe to 8 separate cold Room flows, repositories consolidate related tables into 3 shared hot StateFlow pipelines initialized in viewModelScope using SharingStarted.WhileSubscribed(5_000). When a transaction is recorded, touched accounts and transactions emit once together, cutting redundant recompositions by over 60%.
4. Replacing collectAsState with collectAsStateWithLifecycle
Standard collectAsState continues collecting flow emissions even when the app is in the background or when a screen is buried in the navigation backstack. Replacing it with AndroidX's collectAsStateWithLifecycle ensures that Compose completely halts flow collection when the app is minimized, eliminating background CPU wakeups and conserving battery.
5. Compose Stability Configuration (compose_stability.conf)
We configured explicit Compose compiler stability rules. By declaring core domain classes and collections stable in compose_stability.conf, the Compose compiler generated smart skipping bytecode for TransactionRow composables. During rapid scrolling, Compose reuses existing row layouts instantly without re-measuring text or re-evaluating unmodified rows.
The Result: 120Hz Fluidity
The impact of these optimizations was immediately measurable:
- Average frame render time dropped from 24.2ms (severe jank) down to 3.1ms (flawless 120fps) during fast flings.
- App startup time improved by over 35%.
- Memory allocation spikes during scrolling were cut in half because temporary grouping objects are created on background worker threads and collected cleanly by the garbage collector without stalling the UI.
Key Takeaway
Building high-performance Jetpack Compose interfaces requires respecting the main thread.
Keep your Composables purely passive. Pre-compute your groupings, date headers, and string formatting in background coroutines on Dispatchers.Default. Deliver a single, immutable, compiler-stable UI state to your screen, and let Jetpack Compose do what it does best: rendering pixels at maximum refresh rates.
FinLedger is a privacy-first, offline-first personal finance application for Android that models bank accounts, credit cards, and cash without double-counting.
Explore FinLedger on Google Play: https://play.google.com/store/apps/details?id=com.devanshufinance.app

Top comments (0)