Jank & Rendering Explained
PERFORMANCE › Runtime
Every frame Android draws has a hard deadline, not a soft target. At 60fps you get roughly 16ms to do all the work for that frame, at 90fps roughly 11ms, at 120fps roughly 8ms. That's just 1000 milliseconds divided by the refresh rate. Choreographer drives this deadline: it receives the VSYNC pulse from the display and posts a per-frame callback that runs input handling, animation, measurement, layout, and drawing, all inside that window.
Here's the part interviewers actually probe: if the work isn't finished when the deadline arrives, the frame is not shown late. It is dropped entirely. The system keeps showing the previous frame for one more beat while the late frame's work finishes on the next cycle. That skipped frame, or a run of them, is what a user perceives as jank, a visible stutter rather than smooth motion. So the whole performance topic reduces to one repeated question: what is eating the frame budget, and how do you get the work back under it.
The per-frame work Choreographer schedules is split across two threads, and knowing which one does what is a favorite interview probe. The UI thread, sometimes still called the main thread, runs Choreographer's callback: input, animation, measure, layout, and the draw pass. But onDraw on the UI thread doesn't touch the GPU. It records a sequence of drawing commands into a display list, essentially a recipe for what to paint.
RenderThread is what actually executes that recipe. It picks up the display list and runs DrawFrame, which syncs the latest state from the UI thread, uploads any needed textures, and issues the real GPU draw calls. Both threads have to finish inside the same frame budget, which is why heavy work on either one, a bloated view hierarchy on the UI thread or an expensive display list on RenderThread, produces the identical symptom: a dropped frame. It also means offloading work off the UI thread doesn't automatically fix jank if the GPU-facing work itself is the bottleneck.
Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// input -> animation -> measure -> layout -> record, all on the UI thread
Choreographer.getInstance().postFrameCallback(this)
}
})
Overdraw is painting the same pixel more than once in a single frame. Every extra layer painted over the same area costs GPU fill rate you didn't need to spend, and on a phone screen with millions of pixels that adds up fast during scrolling or animation. A classic case is a parent view with an opaque background color that contains a child which redundantly paints its own opaque background over the exact same area. The parent's paint work was wasted the moment the child covered it.
You diagnose overdraw visually, not by guessing, using Developer Options, Debug GPU Overdraw. It color codes every pixel on screen by how many times it was drawn that frame. True color or blue means the pixel was painted once, which is fine. Red means four times or more, the classic sign of stacked, redundant backgrounds worth flattening or removing.
<!-- 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>
Fixing overdraw usually means deleting redundant backgrounds, flattening view hierarchies, and clipping to only the area that actually needs to be redrawn.
The single most common jank source is mundane: main-thread work that has nothing to do with rendering at all. Synchronous disk or network I/O, heavy JSON or XML parsing, large allocations, or a synchronous Binder call out to another process, all of it competes with Choreographer's frame callback for the exact same thread and the exact same 16 millisecond window. The thread doesn't know the difference between important rendering work and some blocking call you forgot to move off it. It just runs whatever is queued, in order.
The fix is the same pattern every time: get blocking work off the UI thread and only hop back to it to touch views. In Kotlin that typically means launching on Dispatchers.IO for the blocking call and using withContext(Dispatchers.Main) for the final UI update, rather than calling a blocking function directly from a click listener or lifecycle callback.
// BAD: blocks the thread that is also trying to produce frames
fun loadData() {
val result = heavyNetworkCall()
updateUi(result)
}
// GOOD: offload the blocking call, hop back to Main only to update UI
fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
val result = heavyNetworkCall()
withContext(Dispatchers.Main) { updateUi(result) }
}
}
There's a subtler version of main-thread jank worth knowing by name: garbage collection pauses caused by allocation pressure. If a view allocates new objects inside onDraw, or a Composable creates fresh lambdas or data on every recomposition, none of that directly slows the GPU. What it does is generate garbage fast enough that the runtime has to pause to reclaim memory, and that collection work competes for the same main thread that's trying to hit the frame deadline. Do that every frame during a fast scroll and you get intermittent stutter that looks GPU related but isn't.
The fix is the boring one: stop allocating in hot paths. Pre-allocate reusable objects once, outside the draw call, and mutate them in place instead of creating a new instance every frame.
// BAD: allocates a new RectF every frame inside onDraw, GC pressure
override fun onDraw(canvas: Canvas) {
canvas.drawRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
}
// GOOD: pre-allocate once, mutate in place, zero per-frame allocation
private val bounds = RectF()
override fun onDraw(canvas: Canvas) {
bounds.set(0f, 0f, width.toFloat(), height.toFloat())
canvas.drawRect(bounds, paint)
}
Catching main-thread I/O by feel is unreliable, which is why Android ships a dedicated development-time watchdog for it: StrictMode. You configure a thread policy that detects specific violations, disk reads, disk writes, network calls, and can either log them or crash the app immediately so a violation never slips through code review unnoticed. It's strictly a development tool, gated behind a debug build check, never shipped enabled to production users.
// In Application.onCreate(), debug builds only
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork() // flags any main-thread network call
.penaltyLog()
.penaltyDeath() // crash immediately so violations aren't ignored
.build()
)
}
Where StrictMode differs from the measurement tools you'll see next is scope: it doesn't measure frame timing or attribute jank to a screen, it just yells the moment a specific policy violation happens, catching the mistake before it ever becomes a jank report.
Android Vitals turns the vague idea of jank into two concrete, measurable categories, and knowing the exact split is a common interview detail. A slow frame is any frame that takes longer than 16 milliseconds to render. A frozen frame is far more severe, one that takes longer than 700 milliseconds, severe enough that the app visibly looks stuck, though it does eventually recover and keep responding.
Both have a bad behavior threshold the Play Console uses to flag an app. Slow rendering gets flagged once roughly a quarter of an app's frames, about 25%, exceed that 16 millisecond line. Frozen frames get flagged at a much smaller share, roughly 0.1% of frames exceeding 700 milliseconds, because even a small fraction of multi-hundred-millisecond stalls is a bad user experience. Both are non-core vitals, meaning they influence your quality rating without necessarily blocking store placement the way a core vital would.
Frozen frames and ANRs are often confused because they're both about the app looking unresponsive, but they're different mechanisms at different severities. A frozen frame is still a rendering stall, 700 milliseconds to about 5 seconds, the app looks stuck but is still alive and will draw again once the blocking work finishes. An ANR, Application Not Responding, is a distinct system-level event: the main thread has been unresponsive for roughly 5 seconds or more, at which point Android itself gives up waiting and shows the user the ANR dialog rather than just quietly holding the last frame.
The ordering worth memorizing: a slow frame, over 16ms, is less severe than a frozen frame, over 700ms, which is less severe than an ANR, at roughly 5 seconds or more. All three trace back to the same root cause, something blocking the main thread, they just differ in how long the block lasts. Unlike slow and frozen frames, ANR rate is a core Play vital, meaning it carries more weight against your app's store standing.
Once you suspect jank, you need to measure it rather than eyeball it, and the two main tools live in different places. Macrobenchmark, an AndroidX testing library, runs scripted UI interactions, a scroll, a cold start, against a release-like build on a real physical device, and reports frame duration as percentiles: P50, P90, P99. Because it runs like a normal instrumented test, it slots straight into CI, catching a regression before it ever ships, rather than after users start complaining.
@RunWith(AndroidJUnit4::class)
class ScrollBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test
fun scrollFeed() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(FrameTimingMetric()), // captures frame durations
iterations = 5,
startupMode = StartupMode.WARM
) {
pressHome(); startActivityAndWait()
device.findObject(By.res("feed_list")).fling(Direction.DOWN)
}
}
For a deeper look at any single slow frame, Perfetto traces and the Profile GPU Rendering bars break down exactly which stage, measure, layout, draw, or GPU execution, ate the time. Those are manual, one-off investigations though, not something wired into a CI pipeline the way Macrobenchmark is.
Macrobenchmark tells you whether a change regressed performance in a controlled lab run. It says nothing about what real users are experiencing on real, wildly varied hardware in the field. That's the gap JankStats, another AndroidX library, fills. You attach it to a window and it reports per-frame FrameData, including a boolean isJank flag and whatever UI state you've tagged, screen name, feature flag, scroll position, so you can attribute jank to a specific screen or condition instead of a vague aggregate number.
// Setup in Activity.onStart()
val jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) {
Log.w("JankStats",
"Jank: ${frameData.frameDurationUiNanos / 1_000_000}ms " +
"states=${frameData.states}")
}
}
// Tag current screen so reports are attributable
jankStats.addState("screen", "FeedFragment")
The mental model worth keeping: Macrobenchmark answers whether a change regressed performance, measured in CI before release. JankStats answers where jank is actually happening for real users right now, measured continuously in production.
Compose has its own jank source that has nothing to do with the View system: excessive recomposition. Every time a Composable reads a piece of state, Compose has to consider re-running it when that state changes. If a parameter type is unstable, meaning Compose can't prove equality cheaply, or if a Composable reads state that changes on nearly every frame, you get far more recomposition than the actual UI change warrants, and each unnecessary recomposition is more work stacked onto the same frame budget.
The standard toolkit for taming it: mark data classes @Stable or @Immutable so Compose can skip recomposition when nothing meaningful changed, scope state reads as low in the tree as possible so a change only recomposes the small piece that actually needs it, and give LazyColumn and LazyRow items a key so reordering an item doesn't force Compose to treat it as a brand new one.
@Stable
data class Item(val id: String, val name: String)
@Composable
fun ItemList(items: List<Item>) {
LazyColumn {
items(items, key = { it.id }) { item -> // key avoids recomposition on reorder
ItemRow(item)
}
}
}
There's a more specific recomposition trap worth knowing by name: reading a rapidly changing value to compute something that only changes occasionally. Say a scroll-to-top button should appear once the list has scrolled past its first item. If a Composable reads listState.firstVisibleItemIndex > 0 directly, that expression re-evaluates, and can recompose every caller, on literally every scroll pixel, even though the boolean it produces only flips a couple of times in an entire scroll session.
derivedStateOf fixes this by wrapping the computation in its own piece of state that only notifies its readers when the computed result actually changes, not when the underlying input changes. Wrapping it in remember means that derived state survives recomposition of the calling Composable instead of being rebuilt every time.
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 }
}
Even correct, well-threaded code can jank on its very first runs after a fresh install or an update, because the runtime hasn't decided yet that those code paths are hot enough to compile ahead of time. Everything starts out interpreted or JIT compiled on demand, which is slower than optimized native code. A Baseline Profile fixes exactly this window: it's a list of hot methods and classes, startup, common navigation, a scroll path, generated once by driving those interactions with BaselineProfileRule, then shipped inside the APK as baseline-prof.txt.
At install time, the system uses that profile to ahead-of-time, AOT, compile the listed methods, so the very first cold start or first scroll a user experiences already runs as optimized code instead of paying interpreter or JIT costs along the way. It doesn't touch the render pipeline, the GPU, or garbage collection directly, it just removes a specific category of first-run compilation overhead.
// macrobenchmark module, run once to generate the profile
@RunWith(AndroidJUnit4::class)
class StartupBaselineProfile {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.app") {
pressHome()
startActivityAndWait() // captures hot startup path
device.findObject(By.res("feed")).fling(Direction.DOWN) // captures scroll path
}
}