UI Jank Elimination & 60fps Rendering

0/5 in this phase0/52 across the roadmap

📖 Concept

UI jank is visible stutter or frame drops caused by frames taking longer than 16.6ms (60fps) or 8.3ms (120fps). Users perceive jank immediately — it makes apps feel sluggish.

Frame budget:

  • 60fps display: 16.6ms per frame
  • 90fps display: 11.1ms per frame
  • 120fps display: 8.3ms per frame
  • Frame rendering: UI Thread work + RenderThread work must fit in budget

Common jank causes:

  1. Overdraw — Drawing the same pixel multiple times (stacked backgrounds)
  2. Layout complexity — Deeply nested ViewGroups trigger multiple measure passes
  3. Inflate on demand — Inflating complex layouts during scroll
  4. Large images — Loading full-resolution images without downsampling
  5. GC pressure — Allocating objects in onDraw/onBindViewHolder causing GC pauses
  6. Main thread work — Any computation during scroll

Profiling tools:

  • GPU Profiler (on device) — Shows frame rendering time as bars
  • Layout Inspector — View hierarchy depth and overdraw
  • Systrace / Perfetto — System-wide trace showing exactly where time is spent
  • Jetpack Benchmark — Micro and macro benchmarks for measuring performance

Compose-specific jank prevention:

  • Mark classes as @Stable or @Immutable for recomposition skipping
  • Use derivedStateOf for computed values that shouldn't trigger recomposition
  • Defer reads to the Layout/Drawing phase with Modifier.drawWithContent
  • Use key() to help Compose identify items in lazy lists

💻 Code Example

codeTap to expand ⛶
1// RecyclerView optimization for smooth scrolling
2class OptimizedAdapter(
3 private val differ: AsyncListDiffer<Item> = AsyncListDiffer(this, DIFF_CALLBACK)
4) : RecyclerView.Adapter<ViewHolder>() {
5
6 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
7 // Use ViewBinding — faster than findViewById
8 val binding = ItemViewBinding.inflate(
9 LayoutInflater.from(parent.context), parent, false
10 )
11 return ViewHolder(binding)
12 }
13
14 override fun onBindViewHolder(holder: ViewHolder, position: Int) {
15 val item = differ.currentList[position]
16 holder.bind(item)
17 // ❌ DON'T: allocate objects here (called during scroll)
18 // ❌ DON'T: load images without caching
19 // ✅ DO: use Coil/Glide with placeholder
20 }
21
22 companion object {
23 val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Item>() {
24 override fun areItemsTheSame(old: Item, new: Item) = old.id == new.id
25 override fun areContentsTheSame(old: Item, new: Item) = old == new
26 }
27 }
28}
29
30// Compose LazyList optimization
31@Composable
32fun OptimizedList(items: List<Item>) {
33 LazyColumn {
34 items(
35 items = items,
36 key = { it.id } // Stable keys prevent unnecessary recomposition
37 ) { item ->
38 // Use remember to avoid recomputation
39 val formattedDate = remember(item.date) {
40 dateFormatter.format(item.date)
41 }
42 ItemRow(item, formattedDate)
43 }
44 }
45}
46
47// Stable types for Compose recomposition skipping
48@Immutable // Tells Compose this class never changes after creation
49data class UiItem(
50 val id: String,
51 val title: String,
52 val imageUrl: String
53)
54
55// derivedStateOf — only recomposes when the derived value changes
56@Composable
57fun FilteredList(items: List<Item>, query: String) {
58 // Without derivedStateOf: recomposes on EVERY items/query change
59 // With derivedStateOf: only recomposes when filtered result changes
60 val filteredItems by remember(items, query) {
61 derivedStateOf {
62 if (query.isEmpty()) items
63 else items.filter { it.title.contains(query, ignoreCase = true) }
64 }
65 }
66 LazyColumn {
67 items(filteredItems, key = { it.id }) { ItemRow(it) }
68 }
69}

🏋️ Practice Exercise

Practice:

  1. Enable GPU overdraw visualization and reduce overdraw in one of your layouts
  2. Profile a LazyColumn with 1000 items using Compose Compiler metrics
  3. Implement DiffUtil for a RecyclerView and measure the rendering improvement
  4. Use Perfetto to capture a frame trace and identify the jank cause
  5. Add @Stable annotations to your Compose data models and verify recomposition skipping

⚠️ Common Mistakes

  • Allocating objects inside onDraw() or Compose drawing — triggers GC during rendering

  • Not using DiffUtil/keys in lists — causes full rebind on list updates

  • Ignoring overdraw — multiple overlapping backgrounds waste GPU cycles

  • Loading full-resolution images in a list — use thumbnails with Coil/Glide downsampling

  • Not testing on low-end devices — jank may be invisible on flagship devices but severe on budget phones

💼 Interview Questions

🎤 Mock Interview

Mock interview is powered by AI for UI Jank Elimination & 60fps Rendering. Login to unlock this feature.