Open a system trace, know where to look first, and stop guessing at jank. This guide walks the UI thread, RenderThread, SurfaceFlinger and CPU scheduling, using a made-up feed screen called Tidepool as the running example.
Most Android performance arguments go the same way. Someone says the feed “feels janky,” someone else says the main thread is fine, and a profiler screenshot gets pasted into the ticket as proof of both. A system trace ends that argument because it records what the whole device was doing, not just your process, and it stamps everything with one clock.
The catch is that a fresh Perfetto trace is overwhelming. Hundreds of tracks, thousands of slices, and no obvious place to click. This post is the map I wish I had: what the trace contains, how to capture a trustworthy one, how a single frame flows through the system, and how to read the clues that explain why it was late.
Throughout, I use a fictional screen: Tidepool, a feed of cards with thumbnails, a pull-to-refresh animation, and a sticky header. It drops frames when you fling it. We will find out why.
1. What a system trace captures
A trace is not one thing. It is three kinds of evidence recorded against the same timestamps.
Kernel scheduler events. The kernel reports every time a thread is switched onto or off a CPU, when a sleeping thread is woken, and how fast each core is clocked. This layer is the only place you can learn that your thread was ready to run but nobody gave it a core.
App-defined slices. Named intervals emitted by the framework (Choreographer#doFrame, RecyclerView bind work, binder calls) and by you. Slices answer "what did the code believe it was doing."
Frame timeline. On Android 12 and newer, SurfaceFlinger records, for every frame, when it was expected to be presented and when it actually was, along with a jank classification. This is the quickest way to find the frames worth studying.
The value comes from stacking them. A slice tells you bind took 11 ms. The scheduler layer tells you that for 7 of those milliseconds the thread was runnable, not running. Those are completely different bugs with completely different fixes.
Failure mode: reading one layer in isolation. Teams that only look at slices tend to optimize code that was never slow. Teams that only look at CPU tend to blame the scheduler for what is really a lock. Always confirm a finding in a second layer before you change anything.
2. Capturing one you can trust
There are three practical routes. They differ less in output than in repeatability.
Web UI
Go to ui.perfetto.dev, choose Record new trace, connect the phone over WebUSB, tick the categories you need (scheduling, CPU frequency, graphics, view system, input, your app's package), and press record. Reproduce the problem on the device, stop, and the trace opens in the same page.
This is ideal for one-off investigations. Two settings deserve attention before you press the button: the buffer size and the buffer mode. A small ring buffer keeps only the most recent seconds, so if you take thirty seconds to reproduce a hitch that happened early, the evidence is already overwritten.
Command line
For scripted captures, describe the trace in a text config and stream it to the device:
buffers {
size_kb: 65536
fill_policy: RING_BUFFER
}
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "sched/sched_switch"
ftrace_events: "sched/sched_waking"
ftrace_events: "sched/sched_blocked_reason"
ftrace_events: "power/cpu_frequency"
atrace_categories: "gfx"
atrace_categories: "view"
atrace_categories: "input"
atrace_categories: "binder_driver"
atrace_apps: "com.tidepool.app"
}
}
}
data_sources {
config { name: "android.surfaceflinger.frametimeline" }
}
data_sources {
config {
name: "linux.process_stats"
process_stats_config { scan_all_processes_on_start: true }
}
}
duration_ms: 15000
cat tidepool.pbtxt | adb shell perfetto -c - --txt \
-o /data/misc/perfetto-traces/tidepool.pftrace
adb pull /data/misc/perfetto-traces/tidepool.pftrace
The output path matters. The recording is written by Perfetto’s tracing service rather than by your shell, and /data/misc/perfetto-traces/ is the directory the platform's SELinux policy sets aside for that, so it is the safe default. Other locations such as /data/local/tmp/ may be accepted on some Android versions and builds and rejected on others, so start with the documented path. The sched_blocked_reason event is easy to forget and is the one that later tells you which kernel function a stuck thread was waiting in.
Via Macrobenchmark
If a scenario needs to be measured repeatedly, let a benchmark drive it. Each iteration saves its own trace, so a bad run is one click away from its evidence.
@RunWith(AndroidJUnit4::class)
class FeedFlingBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun flingFeed() = rule.measureRepeated(
packageName = "com.tidepool.app",
metrics = listOf(FrameTimingMetric()),
iterations = 8,
startupMode = StartupMode.WARM,
compilationMode = CompilationMode.Partial(),
setupBlock = {
pressHome()
startActivityAndWait()
},
) {
// View system: qualify the id with the package. Compose: enable
// testTagsAsResourceId and use By.res("feed_list") instead.
val feed = checkNotNull(device.findObject(By.res(packageName, "feed_list"))) {
"feed_list not found on screen"
}
feed.setGestureMargin(device.displayWidth / 5)
repeat(3) {
feed.fling(Direction.DOWN)
device.waitForIdle()
}
}
}
Failure mode: benchmarking the wrong build. A debuggable APK, an emulator, or a device at low battery with thermal throttling will produce traces that look plausible and mean nothing. Benchmark a non-debuggable, release-like variant on real hardware, with the device cooled and charged, and treat the first run on a new device as a warm-up.
You can also pass the instrumentation argument androidx.benchmark.fullTracing.enable=true to include extra library tracing (Compose composition slices, for instance) in benchmark traces. Use it for investigation, not for your headline numbers, since extra tracing has a cost of its own.
3. Anatomy of a frame
Every frame starts with a VSYNC signal. The app’s UI thread wakes and runs a slice named Choreographer#doFrame (followed by an id). Inside it, three main phases run in a fixed order (the system also runs insets-animation and commit callbacks, which rarely dominate).
-
Input. Pending touch and key events are delivered. A long
inputslice usually means youronTouch, click or scroll listener is doing real work. -
Animation.
ValueAnimator,ViewPropertyAnimatorand friends tick here. If you see animators doing layout-triggering work every frame, this is where it starts. -
Traversal. The view hierarchy is measured, laid out and drawn.
drawdoes not rasterize pixels; it records drawing commands into a display list.
Then the UI thread synchronizes that recorded state to RenderThread and its work is done. Exact slice names shift between Android releases, so search by prefix rather than by exact string.
Here is a Tidepool-style cause of a fat traversal that I have seen in many codebases: a bind method that quietly measures things.
class CardViewHolder(private val binding: CardBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(card: Card) = trace("Card:bind") {
binding.title.text = card.title
// Text with many spans forces a fresh StaticLayout during measure.
binding.body.text = card.richBody
// Anti-pattern: assigning layoutParams calls requestLayout() on every bind,
// which multiplies traversal cost during a fling.
binding.badge.layoutParams = binding.badge.layoutParams.apply { width = card.badgeWidth }
}
}
The cheaper version only touches layout when the value actually changes:
val lp = binding.badge.layoutParams
if (lp.width != card.badgeWidth) {
lp.width = card.badgeWidth
binding.badge.layoutParams = lp
}
When you scroll the trace to a slow traversal, the child slices (measure, layout, Card:bind, text layout) show where the time went. The label you added with trace is what makes the slow part identifiable at a glance.
Failure mode: trusting one frame. A single long
doFramecan be a garbage collection pause, a one-off class load, or a legitimate cold-path cost. Compare against several frames from the same fling before deciding it is "the bug."
4. Frame budgets
The time you have per frame is the reciprocal of the refresh rate.
At 60 Hz you have roughly 16.6 ms, at 90 Hz about 11.1 ms, and at 120 Hz about 8.3 ms. Two practical consequences follow.
First, work that was “fine” on an older 60 Hz device can fail the budget on a 120 Hz flagship without a single line of your code changing. Second, the budget is for the whole pipeline, not the UI thread alone. The UI thread, RenderThread, the GPU and SurfaceFlinger composition each take a share, and they overlap across consecutive frames rather than running strictly one after another.
Variable refresh rate adds one more trap: the system may pick a lower rate while a screen looks static, then jump up when you start animating. A trace taken only during the jump can show deadline misses that the user never perceives as jank, or hide ones they do. Read the frame timeline track, which compares each frame against the deadline for the rate that was actually in effect.
5. Where RenderThread fits
Since Android 5.0 hardware-accelerated apps split rendering across two threads. The UI thread records what to draw. RenderThread, a separate thread in your process, takes that recorded work and turns it into GPU commands: it replays the display list, issues draw calls, and queues the finished buffer for SurfaceFlinger.
This split is why a fast UI thread does not guarantee a smooth frame.
In the picture, frame 2 spends only 5 ms on the UI thread. If you profile only the main thread, it looks perfect. But RenderThread spends 17 ms on that frame, and the buffer is not ready by the next VSYNC.
What makes RenderThread slow? In my experience, the common culprits are:
-
Overdraw and large layers. Many full-screen translucent surfaces, or a
RenderEffect/blur applied to a big region, cost GPU time. -
Uploading bitmaps on the render path. A software-backed bitmap generally has to be uploaded to the GPU the first time it is drawn, so a fling that brings many new thumbnails into view can cause upload spikes on the frames where they first appear. Treat this as a hypothesis to confirm: if the long
RenderThreadframes do not line up with newly shown images, something else is the cause. -
Waiting for a buffer. If SurfaceFlinger or the display pipeline is holding buffers, the render thread can block in a slice like
dequeueBuffer. A long wait there is not your drawing code being slow; it is back-pressure. - Waiting on the GPU. Slices that mention waiting for GPU completion, or fences, point at GPU-bound work.
Tidepool’s thumbnails are the classic case. Here is the harmless-looking version, which decodes at full resolution into a software bitmap:
suspend fun loadThumbnail(id: String): Bitmap = withContext(Dispatchers.IO) {
BitmapFactory.decodeFile(File(cacheDir, "$id.jpg").path)
?: throw IOException("Could not decode $id")
}
decodeFile returns null when decoding fails, so that case has to be handled explicitly. A better version decodes near the displayed size and, on API 26 and newer, asks for a hardware bitmap so the pixels are placed in graphics memory at decode time instead of on the first draw:
suspend fun loadThumbnail(id: String, targetWidth: Int): Bitmap = withContext(Dispatchers.IO) {
val file = File(cacheDir, "$id.jpg")
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(file.path, it) }
val options = BitmapFactory.Options().apply {
// The decoder rounds down to a power of two anyway; be explicit.
inSampleSize = Integer.highestOneBit((bounds.outWidth / targetWidth).coerceAtLeast(1))
// API 26+. Guard with Build.VERSION.SDK_INT if your minSdk is lower.
inPreferredConfig = Bitmap.Config.HARDWARE
}
BitmapFactory.decodeFile(file.path, options)
?: throw IOException("Could not decode $id")
}
Hardware bitmaps have a trade-off: the CPU cannot read or modify their pixels (no getPixel, no software canvas), so keep a software path for the rare screens that need one. Either way, confirm the improvement in a second trace rather than assuming it. (In a real project an image library does this for you. The point is to recognize the pattern when it appears in RenderThread.)
Failure mode: optimizing the thread you can see. Teams stare at the main thread because it has the friendly name and the app’s code. When the frame timeline marks a frame as janky but the UI thread’s slice is short, stop profiling your Kotlin and go look at
RenderThreadand SurfaceFlinger.
The frame timeline also names who missed: the app, or SurfaceFlinger itself. That classification is the quickest way to avoid blaming your own code for a system-side stall, and to avoid the opposite mistake.
6. Thread states: what each one tells you
Every thread track has a strip showing its scheduler state over time. Learn four of them and you can diagnose most traces.
Running. The thread is on a CPU. Time here is genuine work. The question becomes whether it is the right work, which is where your own slices come in.
Runnable. The thread wants to run and is waiting for a core. Perfetto also shows a preempted variant when the thread was kicked off a CPU by something more important. Large runnable time on the UI thread is a contention signal: the main thread is fine, but it keeps losing the race for a core. This is not fixed by optimizing the code inside the slice.
Sleeping. The thread voluntarily blocked, typically on a lock, a Thread.sleep, a condition, or waiting for a binder reply. Check the slice stacked above the sleeping stretch to see what it was doing, and look at which thread woke it up and when. That wakeup relationship tells you who was holding things up.
Uninterruptible sleep (D). The thread is blocked inside the kernel and cannot be interrupted, most often on storage or a driver. If your main thread shows long D stretches, something is touching disk or a slow subsystem on the UI path. Selecting that stretch shows blocked_function (if you recorded sched_blocked_reason), which names the kernel function it was stuck in.
Here is the Tidepool bug that produces a D-state stripe on the main thread:
override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
val card = cards[position]
// Synchronous disk read during scroll: shows up as D state, not as "slow code".
val cached = File(context.cacheDir, "${card.id}.json").takeIf { it.exists() }?.readText()
holder.bind(card, cached)
}
From a profiler’s CPU view this looks cheap, because the thread is not burning CPU while it waits. In the trace it is unmistakable.
You can also quantify state time with PerfettoSQL instead of eyeballing it. In the Perfetto UI’s query page, this sums main-thread time by state:
SELECT ts.state, ROUND(SUM(ts.dur) / 1e6, 1) AS ms
FROM thread_state ts
JOIN thread USING (utid)
JOIN process USING (upid)
WHERE process.name = 'com.tidepool.app'
AND thread.tid = process.pid
GROUP BY ts.state
ORDER BY ms DESC;
If R and R+ together rival Running, you have a contention problem. If D or DK has a large share, you have an I/O problem. If S dominates inside a frame, you are waiting on a lock or another thread.
One more cause of runnable time is entirely self-inflicted: too many threads competing at the same priority.
class FeedViewModel(private val thumbnails: ThumbnailLoader) : ViewModel() {
// Two threads is plenty for prefetch; more just steals cores from the UI thread.
private val imageDispatcher = Dispatchers.IO.limitedParallelism(2)
private val inFlight = mutableMapOf<String, Job>()
fun prefetch(ids: List<String>) {
ids.filterNot { it in inFlight }.forEach { id ->
// viewModelScope ties the work to the screen's lifetime.
inFlight[id] = viewModelScope.launch(imageDispatcher) {
try { thumbnails.load(id) } finally { inFlight.remove(id) }
}
}
}
// Call when rows scroll far out of range or the fling direction flips.
fun cancelPrefetch(ids: List<String>) = ids.forEach { inFlight.remove(it)?.cancel() }
}
Bounding concurrency keeps the main thread and RenderThread from queuing behind your own prefetch work.
Failure mode: assuming runnable means “my code is slow.” Runnable is time spent not executing your code. Fix it by reducing competing work or changing priorities, not by micro-optimizing methods.
7. Which CPU core ran your thread
Phones are heterogeneous. A typical chip has a cluster of small, efficient cores, some mid-range ones, and one or a few high-performance cores. The same work finishes much faster on a fast core running at a high clock.
In the trace, each running stretch records the CPU id, and the CPU frequency counters show how hard that core was clocked. Combine the two. A 6 ms section that quietly became 16 ms probably did not change; it moved to a slow core, or the core had a low frequency when the section began.
To map CPU numbers to clusters on your device, check the maximum frequencies:
adb shell 'for c in /sys/devices/system/cpu/cpu[0-9]*; do
echo "$(basename $c) $(cat $c/cpufreq/cpuinfo_max_freq 2>/dev/null)"
done'
Cores sharing a maximum frequency belong to the same cluster. Numbering and cluster sizes vary between chips, so do not assume that cpu7 is always the fastest.
A frequent reason a latency-sensitive thread lands on small cores is that it was demoted on purpose, often by you:
// One long-lived, named, background-priority worker owned by a singleton,
// instead of a fresh Thread per request.
private val indexingDispatcher = Executors.newSingleThreadExecutor { task ->
Thread({
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
task.run()
}, "tidepool-indexer")
}.asCoroutineDispatcher()
suspend fun reindex() = withContext(indexingDispatcher) { heavyIndexing() }
Background-priority threads are generally scheduled with a restricted CPU set and a small share of time. If the UI thread then blocks on that worker, you have turned a background task into a foreground stall, and the trace will show the main thread sleeping while its dependency crawls along on a little core.
Frequency ramp-up is the quieter cousin. When a screen has been idle, cores clock down, and the first frames of an animation run before the governor reacts. If only the first few frames of a transition are slow, check the frequency counter before rewriting any code.
Failure mode: comparing runs across different thermal states. A device that has been benchmarking for ten minutes throttles its fast cores. Two traces of identical code can differ by 30 percent for reasons that live entirely in the frequency track.
8. Adding your own trace sections
The framework’s slices are generic. Yours are what turn a trace into a story about your app. Add the androidx.tracing:tracing-ktx dependency and wrap meaningful units of work.
import androidx.tracing.trace
fun buildFeedModel(raw: List<Dto>): List<Card> = trace("Feed:buildModel") {
raw.map { dto -> trace("Feed:mapDto") { dto.toCard() } }
}
A few rules keep this useful rather than noisy:
-
Name by intent, not by class.
Feed:buildModelbeatsFeedMapper.map. You will be searching for these names in a query box. -
Do not build expensive labels in hot paths. A string template evaluated on every bind allocates even when no trace is running. Keep labels constant, or guard dynamic ones with
Trace.isEnabled(). - Mind the length. Section names are truncated at 127 characters.
- Sync sections nest. They must begin and end on the same thread, in stack order.
Async sections for work that crosses threads
A synchronous slice is a stack frame on one thread. Real work often is not: a request begins on the main thread, runs on an I/O thread, and completes back on main. For that, use async sections, which are tied to a name and a cookie rather than to a thread.
import androidx.tracing.Trace
import java.util.concurrent.atomic.AtomicInteger
@PublishedApi
internal val asyncCookies = AtomicInteger()
suspend inline fun <T> traceAsync(name: String, crossinline block: suspend () -> T): T {
val cookie = asyncCookies.incrementAndGet()
Trace.beginAsyncSection(name, cookie)
try {
return block()
} finally {
Trace.endAsyncSection(name, cookie)
}
}
class ThumbnailLoader(private val io: CoroutineDispatcher = Dispatchers.IO) {
suspend fun load(id: String): Bitmap = traceAsync("Thumb:load") {
withContext(io) { trace("Thumb:decode") { decode(id) } }
}
}
Now the whole round trip shows up as one continuous bar on an async track, while the inner Thumb:decode slice sits on the I/O thread where the CPU time actually went. When ten thumbnails load at once, each gets its own cookie, so the slices do not collide. The cookie only has to be unique among sections that overlap in time, so the counter wrapping around after billions of calls is harmless. Import Trace from androidx.tracing, as above, rather than calling android.os.Trace directly: the platform async API needs API 29, while the AndroidX wrapper is built to be safe on lower minSdk values.
Two failure modes here are worth burning into memory.
Failure mode: a synchronous section wrapped around a suspension point. This compiles, and it is wrong:
suspend fun refresh() = trace("Feed:refresh") { // begins on main
withContext(Dispatchers.IO) { api.fetch() } // may resume on another thread
}
If the coroutine resumes on a different thread,
endSectionpops the wrong thread's stack and you get orphaned or truncated slices that poison everything around them. Use an async section for anything that suspends.Failure mode: leaked async sections. If you call
beginAsyncSectionand a cancellation or exception skipsendAsyncSection, the slice stays open to the end of the trace and looks like a ten-second operation. Thetry/finallyin the helper above is not decoration, because coroutine cancellation is an exception path you will hit constantly.
Finally, make your sections measurable in CI. A Macrobenchmark can report the time spent in a named section directly, so a regression in Feed:buildModel fails a build rather than waiting for a user complaint:
@OptIn(ExperimentalMetricApi::class)
val metrics = listOf(
FrameTimingMetric(),
TraceSectionMetric("Feed:buildModel"),
)
Putting it together: a triage order
When someone hands you a trace and says “it janks,” this is the sequence I follow:
- Find the bad frames. Open the app’s frame timeline track and look for frames flagged as missed. Note the jank type and whether it blames the app or SurfaceFlinger.
-
Zoom to one
Choreographer#doFrame. Check how long the UI thread actually took against the budget for the active refresh rate. -
Check
RenderThreadfor the same frame. If the UI thread was short but the frame was late, look for longDrawFramework, bitmap uploads, or a stalleddequeueBuffer. - Read the thread state strip. Runnable means contention, sleeping means a lock or dependency, D means storage or a driver, and running means real work.
- Check the core and the frequency. Confirm it was not a little core at a low clock.
- Add slices and recapture. If you cannot explain a gap, instrument it, capture again, and compare.
🔚 Closing thoughts
Perfetto is less intimidating once you notice it answers only a handful of questions: which frame was late, which thread was responsible, what state that thread was in, and where it ran. The skill is not memorizing track names. It is resisting the urge to optimize before the trace has told you which kind of slow you have.
For Tidepool, the answer turned out to be three separate problems stacked together: full-size thumbnail uploads on RenderThread, a synchronous cache read in onBindViewHolder showing up as D state, and an unbounded prefetch fan-out creating runnable time on the main thread. No single fix would have made it smooth, and no single tool except a system trace would have shown all three.
Capture one on your slowest screen this week, and start with the frame timeline.
📱 Stop Guessing Whether Your Android Code Actually Works
If “we’ll add tests later” is a sentence your team says a little too often, I’d recommend “Mastering Android Testing” — a 35-chapter deep dive into why Android testing is uniquely hard (Activity/Fragment coupling, async complexity, tooling churn) and exactly how to fix it, layer by layer. It walks through architecting for testability, JUnit/Truth/MockK fundamentals, ViewModel and coroutine/Flow testing without flakiness, Compose UI testing and the semantics tree, Espresso and instrumented tests, Room/Retrofit data-layer testing with a full sync-conflict-resolution case study, Robolectric’s real limits, CI pipelines, flaky test triage, and how to build a testing culture that actually survives contact with a deadline.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.








Top comments (0)