Jank & Rendering Explained
PERFORMANCE › Runtime
Every frame Android draws has a hard deadline: at 60fps you get about **16ms** to do all the work for that frame, at 90fps about **11ms**, at 120fps about **8ms** (1000ms divided by the frame rate). This isn't a soft target you can slightly overrun, Choreographer drives the frame pipeline off the display's VSYNC pulse, and if the work isn't ready in time, the entire frame is dropped, not shown late, just skipped, and the previous frame holds a beat longer. That skipped frame is what the user perceives as **jank**, a stutter.
The whole topic is really one question repeated in different clothes: what's eating the frame budget, and how do you get it back under 16ms (or whatever your target is)?
The render pipeline is split across two threads, and knowing which one does what is a common interview probe:
- **UI or main thread**: driven by Choreographer's per-frame callback, it handles input, animation, and the measure or layout or draw pass, recording drawing commands into a **display list** (it doesn't touch the GPU directly). - **RenderThread**: picks up that display list and runs DrawFrame, syncing state, uploading textures, and issuing the actual GPU draw calls.
Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// input -> animation -> measure -> layout -> draw, all on the UI thread
Choreographer.getInstance().postFrameCallback(this)
}
})
Both stages have to finish inside the same frame budget, so blocking either one, heavy work on the UI thread, or a GPU-bound draw list, produces the same symptom: a dropped frame.
**Overdraw** is painting the same pixel more than once within a single frame, every extra layer costs GPU fill rate you didn't need to spend. A classic case is a parent view with a background color, containing a child that redundantly paints its own opaque background over the same area:
<!-- BAD: child repeats the parent's background, 2x overdraw -->
<FrameLayout android:background="@color/white">
<TextView android:background="@color/white" ... />
</FrameLayout>
<!-- GOOD: drop the redundant background, 1x -->
<FrameLayout android:background="@color/white">
<TextView ... />
</FrameLayout>
You diagnose it visually with Developer Options > **Debug GPU Overdraw**, which color-codes each pixel by how many times it was drawn that frame, true color or blue means 1x (fine), red means 4x or more (a real problem worth fixing).
The single most common jank source, though, is mundane: **main-thread work that has nothing to do with rendering**. Synchronous disk or network IO, heavy parsing, large allocations that provoke GC, or a synchronous Binder call to another process, all of it competes with Choreographer's frame callback for the same thread and the same 16ms window.
// BAD: blocks the thread that's also trying to produce frames
fun loadData() {
val result = heavyNetworkCall()
updateUi(result)
}
// GOOD: offload, then hop back to Main to touch the UI
fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
val result = heavyNetworkCall()
withContext(Dispatchers.Main) { updateUi(result) }
}
}
This is also where jank and ANRs blur together at the extreme end: a few dropped frames from a slow main-thread call is jank, the same class of bug held for around 5 seconds becomes an ANR.
Android Vitals turns "jank" into concrete, measurable categories:
- **Slow frames**: take longer than 16ms. Flagged as bad behavior once roughly **25%** of an app's frames exceed it. - **Frozen frames**: take longer than **700ms**, severe enough that the app looks stuck, but it does recover. Flagged once roughly **0.1%** of frames exceed it. - **ANR**: the related core vital, but a different mechanism entirely, main-thread unresponsiveness of roughly 5 seconds or more, past which the system shows the "App Not Responding" dialog rather than just a stuck-looking frame.
The ordering to remember: slow frame (>16ms) < frozen frame (>700ms) < ANR (~5s+). They're all downstream of the same root cause, blocking the main thread, just at different severities.
Compose has its own jank source: **excessive recomposition**. A composable that reads state which changes on every scroll pixel recomposes on every pixel, even if the value it actually needs (say, a boolean) only flips occasionally:
val listState = rememberLazyListState()
// BAD: re-evaluated, and recomposes callers, on every scroll pixel
val showButton = listState.firstVisibleItemIndex > 0
// GOOD: recomposes only when the boolean actually flips
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
derivedStateOf wraps a computation so downstream recomposition is triggered only when its *result* changes, not on every read of the rapidly-changing input. Combine that with stable or immutable parameter types and key on lazy list items, and you cut most of the unnecessary recomposition that shows up as scroll jank.
For measuring jank rather than guessing, there are two complementary tools with different homes:
- **Macrobenchmark**'s FrameTimingMetric runs scripted UI interactions on a real device against a release-like build, and reports frame-duration percentiles (P50 or P90 or P99). It's built for **CI**, catching regressions before they ship. - **JankStats** is an AndroidX library for **production or field** monitoring. It uses FrameMetrics under the hood and reports per-frame FrameData, including isJank and attached UI state, so you can attribute real-user jank to a specific screen.
val jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) Log.w("Jank", "on ${frameData.states}")
}
jankStats.addState("screen", "FeedFragment")
Think of it as: Macrobenchmark answers "did this change regress performance", JankStats answers "where is jank actually happening for real users right now".