Journal · Jun 16, 2026

Mid-range is the main stage

Most Android users have never held the phone your app was developed on. Performance budgets, Compose pitfalls, baseline profiles, and a testing matrix for the devices people actually own.

Walk through any Android team's office and count the phones on desks: Pixels and Galaxy S-series flagships, almost without exception. Now look at the install base those apps ship to. The best-selling Android devices worldwide, year after year, are the A-series Samsungs, the Redmis, the budget Motorolas — phones with mid-tier silicon, 4 to 8 GB of RAM, eMMC or entry-level UFS storage, and screens that mostly refresh at 60 or 90 Hz. The flagship your app was developed on is the exception in its own user base.

That mismatch is the quiet root cause behind a huge share of Android performance complaints. The app isn't slow. The app is slow on the hardware people actually own, which the team never feels because they never hold it. Mid-range isn't the edge case to accommodate at the end. It's the main stage, and we build for it deliberately.

The flagship in your pocket is lying to you

A current flagship SoC has enormous single-core headroom, aggressive memory bandwidth, fast storage, and thermal capacity that hides sins for minutes at a time. A mid-range device from the same year has a fraction of that — and a three-year-old mid-ranger, which is a completely normal thing for your user to own, is further behind still. Cheaper devices also throttle earlier and harder: the demo that's smooth for the first ninety seconds can degrade measurably once the SoC heats up.

The practical consequence: a jank problem that's invisible on the dev phone is a one-star review on a Galaxy A15. You cannot feel your way to this. You have to measure on representative hardware, which is why everything below keeps coming back to the same theme — put real mid-range devices in the loop, and let them veto.

Budgets set on hardware you don't carry

We covered the philosophy of performance budgets for the web in an earlier post; on Android the same idea applies with different line items. The budgets that matter, all measured on a designated mid-range reference device — not the emulator, not a Pixel:

  • Cold start: app usable fast enough that the system never shows the "app isn't responding" affordance, with our own target well under two seconds to first meaningful content on the reference device.
  • Frame time: hitting the display's frame deadline consistently matters more than peak smoothness — a stable 60 Hz beats an erratic 120. We track jank rate (janky frames as a percentage) via JankStats and Play Vitals, not by eyeballing.
  • Memory: survive comfortably in a 4 GB-device world where the OS is far more eager to kill background processes. State restoration isn't polish; on mid-range hardware, process death is a routine event.
  • APK/AAB size: storage pressure is real on 64 GB devices, and download size measurably affects install conversion.

Play Vitals then gives you the field truth, sliced by device model. If your p90 cold start looks fine but the A-series cohort doesn't, you have your answer about where to spend the next sprint.

Compose pitfalls that flagships hide

We build UI in Jetpack Compose by default, and Compose on a flagship is very forgiving — recomposition waste that a Snapdragon 8-series absorbs silently becomes visible jank two tiers down. The recurring offenders in codebases we audit:

Reading state too high. A frequently-changing value — scroll offset, animation progress — read in a large composable recomposes the whole subtree at that frequency. Push reads down into the smallest possible scope, or into the draw phase via lambda-based modifiers like graphicsLayer, which skip recomposition entirely.

Recomposing on every tick of something derivable. The classic: reacting to listState.firstVisibleItemIndex directly when all you need is a boolean.

val showFab by remember {
  derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

derivedStateOf collapses a stream of index changes into the two transitions you actually care about.

Unstable parameters defeating skipping. Compose skips recomposing a composable whose inputs haven't changed — but only when it can prove the inputs stable. Collections typed as List, classes from modules without the Compose compiler, and similar patterns silently disable skipping. The strong-skipping mode that recent Compose compiler versions enable by default removed a lot of this pain (and made the old habit of wrapping every callback in remember largely unnecessary), but data modeling still matters: immutable UI models, stable keys.

Lazy lists without key and contentType. Both omissions are invisible on fast hardware and expensive on slow hardware, where item churn during scroll turns directly into dropped frames.

The meta-pitfall is diagnosing any of this by intuition. Layout Inspector's recomposition counts and Macrobenchmark traces on the reference device tell the truth; the dev phone does not.

Baseline profiles are not optional

Android runs your app as a mix of interpreted, JIT-compiled, and ahead-of-time-compiled code. On first launches after install or update, before the JIT has learned anything, a mid-range CPU is interpreting your hottest paths — and that's precisely the moment the user forms their opinion. Baseline Profiles fix this: you record the critical journeys (startup, first scroll), ship the profile with the app, and Play's install pipeline compiles those paths ahead of time on-device.

@Test
fun startupProfile() = rule.collect(
  packageName = "com.example.app",
  includeInStartupProfile = true,
) {
  pressHome()
  startActivityAndWait()
  device.findObject(By.res("feed"))?.fling(Direction.DOWN)
}

Google's published figures and our own Macrobenchmark runs agree on the shape of the win: double-digit percentage improvements in cold start and first-use jank, with the effect most pronounced exactly where you need it — slower devices, where interpretation costs the most. Libraries ship their own profiles now (Compose includes one), but they don't know your user journeys. Generating and verifying an app-level profile is a day of work the first time and nearly free thereafter. For a mid-range audience there is no better performance-per-effort trade on the platform.

A testing matrix that reflects the install base

Last piece, and the one that makes the rest stick: test where your users are.

  • Own the hardware. A small bench of real devices — one flagship, two current mid-rangers, one three-to-four-year-old budget phone, ideally one Android Go device if your market includes one. This costs less than a conference ticket.
  • Rent the breadth. Firebase Test Lab or an equivalent device farm for the long tail of OEM skins and API levels. OEM battery managers and background-execution quirks are their own genre of bug that pure-AOSP devices never show you.
  • Weight by analytics, not by desk inventory. Your device matrix should mirror your actual install base, refreshed quarterly. If 60% of sessions come from mid-tier Samsungs, that's where 60% of the device-testing time goes.
  • Test the unhappy conditions. Throttled thermals, Doze, data saver, 3G-grade networks, low-storage states. Mid-range users live in these conditions routinely; apps that assume otherwise are the ones with mysterious field-only bugs.

None of this is heroic engineering. It's mostly the humility to accept that the phone in your pocket is a poor proxy for your users' — and the discipline to let a $180 device hold veto power over the release. The teams that internalize that ship Android apps people describe as fast. The teams that don't ship apps that are fast in the office.