Skip to content
article

Finding jank on a Rp1,5 juta phone: what actually worked for our Compose feed

Numbers from a real feed screen, measured on the phone our users actually have

Dita Anggraini@dita_kotlin

September 8, 2026 · 3 min read

Promised Trey a proper post after his budget Android review, so here it is. Context: big app, Compose UI, and most of our users are on phones that cost Rp1,5 to 2,5 juta (roughly 90 to 160 USD). Our feed felt "fine" on our flagship dev phones. On the cheap test device it was not fine.

1. Measure first, on the right phone

Quick check, no setup:

adb shell dumpsys gfxinfo com.example.app reset
# scroll the feed for 30 seconds
adb shell dumpsys gfxinfo com.example.app

Look at "Janky frames" and the 90th, 95th and 99th percentile frame times. Our feed: 8.2% janky frames on the cheap phone, 1.1% on a flagship. The 50th percentile was fine on both. The 99th percentile on the cheap phone was 61 ms. That is what users feel as "laggy".

For repeatable numbers we run Macrobenchmark with FrameTimingMetric in CI:

@RunWith(AndroidJUnit4::class)
class FeedScrollBenchmark {
    @get:Rule val rule = MacrobenchmarkRule()

    @Test
    fun scrollFeed() = rule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(FrameTimingMetric()),
        iterations = 10,
        startupMode = StartupMode.WARM,
        compilationMode = CompilationMode.Partial(),
    ) {
        startActivityAndWait()
        val feed = device.findObject(By.res("feed_list"))
        feed.setGestureMargin(device.displayWidth / 5)
        repeat(3) { feed.fling(Direction.DOWN) }
        device.waitForIdle()
    }
}

(For Compose you need Modifier.semantics { testTagsAsResourceId = true } near the root, so By.res can find the test tag.)

2. What we found, biggest first

a. Unstable parameters, so every item recomposed

Our feed item took a List<Tag> and a domain model from another module. Compose treated both as unstable, so every item recomposed whenever anything in the parent changed. The compiler reports showed it straight away:

// build.gradle.kts (module)
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_compiler")
}

Fix: a UI model with ImmutableList from kotlinx.collections.immutable and @Immutable on the class. (Strong skipping mode in newer Compose compiler versions helps a lot here too, check which version you are on.) Recompositions per scroll dropped about 70%.

b. No keys in LazyColumn

LazyColumn {
    items(items = posts, key = { it.id }, contentType = { it.type }) { post ->
        FeedItem(post)
    }
}

Without key, inserting a new post at the top shifted state for every item. contentType lets Compose reuse compositions between items of the same type.

c. Reading scroll state in composition

A "scroll to top" button read listState.firstVisibleItemIndex directly, so the whole screen recomposed on every scroll frame. Fix:

val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 3 }
}

d. Decoding thumbnails at full size

Thumbnails were decoded at 1080px and then shown at 120dp. Setting the size explicitly in the image request, so the loader decodes at display size, cut memory a lot and removed most of the GC pauses during scroll.

e. Baseline Profile

We generated a Baseline Profile covering startup and feed scroll. Without it, the first scroll after install runs a lot of code interpreted or JIT compiled. On the cheap phone, first-scroll jank dropped from 14% to 6%. The biggest single win for "first impression", and it is mostly configuration.

Result

                         before    after
janky frames (cheap)       8.2%     1.9%
p99 frame time (cheap)     61 ms    24 ms
janky frames (flagship)    1.1%     0.4%

What I would tell other Android teams

  • Your dev phone is lying to you. Buy the phone your median user has and keep it on your desk.
  • Look at p95 and p99 frame time, not the average. The average hides everything.
  • Most of our wins were "stop doing work", not clever code.

And no, rewriting it in a cross-platform framework would not fix any of this. Sorry anoop.

Written by

Dita Anggraini

Android engineer, Jakarta. Kotlin, Compose, and apps that must work on a Rp1,5 juta phone on a moving TransJakarta bus.

Get the best of #thumb_zone weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

9

Sign in to join the conversation.

Sign in to comment
Trey Robinson@techwithtrey#8 in Thumb Zone
This is exactly what I meant when I said I don't have the tools. Using the gfxinfo trick in every phone review from now on, credited to you. Thank you Dita 🔥
2 net votes
Bartek Wójcik@bartek_gamedev1 rep in Thumb Zone
p99 not average, yes! same in games, average fps is a lie, 1% lows is the truth. good post
5 net votes
Dita Anggraini@dita_kotlin#1 in Thumb Zone
Exactly. Nobody ever complains about the average frame.
1 net votes
Anoop Menon@anoop_flutter#4 in Thumb Zone
Okay okay, good post, I admit it 😂 but Flutter has the same tools machane, DevTools shows UI and raster thread time per frame, and the cheap phone lesson applies everywhere. Also 'sorry anoop' in public, I'm framing it
7 net votes
Dita Anggraini@dita_kotlin#1 in Thumb Zone
I know Flutter has tools. I am just reminding you that you also need them.
7 net votes
Artyom Sokolov@relocant_artyom1 rep in Thumb Zone
Great writeup. The derivedStateOf one is so common, we had the exact same scroll-to-top bug. One addition: watch for heavy work in modifiers like onGloballyPositioned during scroll. We had a tooltip anchor doing layout work every frame.
6 net votes
Nurul Aisyah@nurul_sql#8 in Thumb Zone
not an android person but 'buy the phone your median user has' is so true lah. my bank's app team test on iphone pro only, then wonder why customers give 2 stars
3 net votes
Keron Baptiste@keron_tt1 rep in Thumb Zone
Saved. My clients' customers are 100% on this tier. Baseline Profile is next on my list, never set one up before.
2 net votes
Tunde Adebayo@tunde_ade#2 in Thumb Zone
dita you just wrote the post i been meaning to write for 2 years and couldnt be bothered. respect 🙌
5 net votes