Jank Hunting: Frame Timing Beyond "It Feels Smooth"
"It feels smooth" is a claim about your device, not your app. Jank is a distribution problem: measure P99 and frozen frames with FrameTimingMetric in CI and JankStats in production, and only then open a trace.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Jank is a distribution problem, not an average one. The P50 frame is always fine; users
experience the tail and remember the frozen frames. Measure in three places, in order: FrameTimingMetric
in Macrobenchmark to fail CI on a P99 regression, JankStats with attributed state in production to
learn which screen, and Perfetto last to learn why. And retire the 16ms budget — on a 120Hz panel
you have 8.3ms, and variable refresh rate changes it mid-animation.
"It feels smooth" is a claim about your device
The phone in your pocket is a fast one, warm, on WiFi, with your app already in memory and its caches populated by a week of development. Your P90 user has a mid-range device from two years ago, 3GB of usable RAM, a cold cache, and eleven other apps resident.
Every performance conversation that starts with "it feels fine to me" is a sampling error stated as a conclusion. The fix isn't a better opinion — it's a number that survives being disagreed with.
Jank is a distribution, not an average
This is the part most teams get wrong, and it invalidates the metric they usually pick.
Average frame time is a useless statistic for jank. An app that renders 999 frames in 4ms and one frame in 900ms has an excellent average and a user who saw the screen freeze. Smoothness is not the mean of the frames — it's a property of the worst ones, because a dropped frame is visible and a fast frame is not rewarding.
So the metric is percentiles, and the two that matter are:
- P99 frame duration — the tail your users actually feel during a scroll.
- Frozen frames (>700ms) — where the app stops being "janky" and starts being "broken". One frozen frame does more reputational damage than a thousand slow ones, and it's the thing that gets typed into a one-star review.
P50 is not a diagnostic. It will be green on the day your app is unusable.
The frame budget is not 16ms
The 16.67ms figure comes from a fixed 60Hz panel, and that assumption has quietly expired. A 120Hz device gives you 8.3ms; 90Hz gives you 11.1ms. Worse for your mental model, modern panels are variable-refresh — the budget can change during the animation you're profiling.
The practical consequence: a frame that comfortably fits a 60Hz budget can still be jank on the flagship
your CEO uses. Don't hard-code the number. Display.getRefreshRate() and the frame timeline know it;
your assumptions don't.
Measure in CI: FrameTimingMetric
This is the only measurement that prevents jank rather than documenting it.
@Test
fun feedScrollFrames() = benchmarkRule.measureRepeated(
packageName = PACKAGE,
metrics = listOf(FrameTimingMetric()),
compilationMode = CompilationMode.Partial(),
startupMode = StartupMode.COLD,
iterations = 10,
) {
startActivityAndWait()
device.findObject(By.res("feed")).fling(Direction.DOWN)
}
You get frameDurationCpuMs at P50/P90/P95/P99 and frameOverrunMs — how far past the deadline the
frame went, which is the more honest number because it's measured against the device's real budget
rather than a constant.
Two details decide whether this is useful or theatre:
CompilationMode.Partial(). Benchmarking a JIT-only build measures a state no user is in, and the numbers will be dramatically worse than production. If you ship Baseline Profiles, benchmark with them.- Run it on a real, mid-range device, pinned to one model. Emulator frame timings are fiction, and changing device between runs turns your regression graph into noise.
Then wire it to the build: a P99 regression over a threshold fails the PR. Without that last step you have a dashboard nobody opens.
Measure in production: JankStats with attribution
CI tells you a journey you thought to test got slower. Production tells you which of the forty screens you didn't test is bad on hardware you don't own.
private val jankStats = JankStats.createAndTrack(window) { frame ->
if (frame.isJank) {
analytics.log(
"jank",
durationMs = frame.frameDurationUiNanos / 1_000_000,
states = frame.states.associate { it.key to it.value },
)
}
}
The listener is the boring half. The valuable half is state:
val holder = PerformanceMetricsState.getHolderForHierarchy(view).state
holder?.putState("screen", "feed")
holder?.putState("mediaPlaying", isVideoVisible.toString())
Attributed jank says "P99 on the feed, only while a video is visible, only on Mali GPUs." Unattributed jank says "some frames were slow" — technically a metric, practically a shrug. If you only do one thing from this post, add the states.
Sample it. You do not need every jank frame from every user; you need enough to compare screens.
Only now, the trace
With a screen and a condition identified, a Perfetto trace answers why in one afternoon — and there's a whole post in this series on reading one. The mistake is starting there: opening a trace without a target means capturing your fast device doing something that wasn't slow, finding nothing, and declaring the app fine.
What you'll usually find, in rough order of frequency: main-thread I/O (a SharedPreferences read, a
disk-backed Room query without a Flow), image decode on the UI thread, allocation churn triggering GC
during a fling, a layout pass caused by a View measured twice, and in Compose, recomposition of a
subtree that could have been a lambda-deferred read.
The rule
Jank you can't attribute is jank you can't fix. Get the number in CI so it can't get worse, get the attribution in production so you know where it already is, and spend the trace on the screen those two agreed on. "It feels smooth" is a hypothesis; frame timing is the experiment.