Tracking Frozen Frames (>700ms) and UI Jank in Compose Using JankStats and Android Vitals
Few issues damage an Android app's user experience faster than UI jank. A user scrolls through a catalog, taps a checkout button, and the screen locks for nearly a second. To the operating system, the application has not crashed. But to the user, the app feels broken, unresponsive, and untrustworthy.
Google monitors mobile user experience through Android Vitals. While Google Play's core vitals focus on user-perceived crash rate, user-perceived ANR rate, and battery usage (excessive partial wake locks), Android Vitals also tracks rendering performance through Slow Frames (>16ms) and Frozen Frames (>700ms). Although frozen frames do not carry a standalone store discoverability penalty or documented bad behavior threshold like core vitals, prolonged frame hangs directly damage user retention, spark negative reviews, and frequently cascade into full-blown Application Not Responding (ANR) errors.
In declarative UI toolkits like Jetpack Compose, diagnosing frame hangs is challenging because recomposition, layout passes, and drawing occur on a unified render pipeline. Traditional crash reporting tools capture uncaught exceptions, but remain blind to real-world Jetpack Compose jank and 700ms frame drops on the main thread.
To eliminate this blind spot, modern frame drop profiling requires Android's JankStats library (androidx.metrics:metrics-performance). By pairing JankStats with contextual UI state and telemetry, engineering teams can track, isolate, and eliminate frozen frames before they degrade the user experience.
How Android Vitals Measures Rendering: Slow Frames vs. Frozen Frames
Google Play continuously collects system rendering metrics across your active production install base via Android Vitals. Google tracks two key rendering metrics:
- Slow Frames (>16ms): Frames that miss the hardware vsync interval (exceeding ~16.6ms on 60Hz displays or ~8.3ms on 120Hz panels). Android Vitals defines this metric as the percentage of daily sessions in which more than 50% of frames took longer than 16ms to render.
- Frozen Frames (>700ms): Severe rendering stalls where a single frame takes longer than 700 milliseconds to render (equivalent to dropping more than 42 consecutive frames at 60Hz). Android Vitals measures this as the percentage of daily sessions during which more than 0.1% of frames took longer than 700ms to render.
Notice the distinction: the 0.1% metric in Android Vitals represents the share of frames within a session that freeze, not a percentage of overall user sessions.
Core Vitals vs. Rendering Metrics
It is important to understand how Google Play treats rendering metrics relative to Core Vitals:
- Core Vitals: Google Play designates four core vitals: user-perceived ANR rate, user-perceived crash rate, and battery metrics (excessive partial wake locks and excessive background network/Wi-Fi usage). Only Core Vitals carry documented "bad behavior thresholds" that directly trigger algorithmic search deprioritization, disqualification from promotional placement, and warning banners on store listings.
- Rendering Metrics: Slow rendering and frozen frames are not core vitals, and Google Play does not document an official discoverability penalty or bad behavior threshold for them.
However, treating frozen frames as benign is a critical mistake. When a frame takes 700ms or longer to render, the main looper is completely starved. If a user touches the screen or the system sends an input event during this freeze, the input queue stalls. If the main thread remains blocked for 5 seconds, Android triggers a User-Perceived ANR, immediately triggering Google Play's strictest Core Vital penalties.
The Business and Reliability Impact of Frozen Frames
While slow frames cause perceptible micro-stutter during scrolling, frozen frames represent complete UI paralysis.
Even without a direct store discoverability penalty, frozen frames severely degrade an application's product health:
- Escalation to User-Perceived ANRs: A 700ms freeze is often symptomatic of synchronous disk I/O, main-thread serialization, or heavy layout passes. Under real-world device throttling or thermal load, these 700ms stalls easily stretch into multi-second hangs that trigger ANRs.
- Conversion and Task Abandonment: When a tap on a "Pay Now" or "Add to Cart" button freezes the screen for 700ms+, users assume the action failed. They either rage-tap (causing duplicate actions or further freezing) or leave the application entirely.
- Review Sentiment and Play Store Ratings: Users rarely distinguish between a frozen frame and an actual crash. Stalls over 700ms are the number one driver of reviews reporting "app keeps freezing" or "unresponsive," dragging down overall star ratings and organically reducing install conversions.
Fixing frozen frames is essential engineering hygiene that directly defends your app's Core Vitals and user retention.
Setting Up JankStats in Jetpack Compose
The AndroidX JankStats library (androidx.metrics:metrics-performance) tracks per-frame render times using platform Window.OnFrameMetricsAvailableListener on API 24+ while maintaining backward compatibility down to API 16.
Add the dependency to your module's build.gradle.kts:
dependencies {
implementation("androidx.metrics:metrics-performance:1.0.0")
}
Initializing JankStats in Your Main Activity
JankStats must be attached to the current Window. In a Compose-based Activity, initialize the listener inside onCreate() or a lifecycle-aware effect:
package com.example.app.performance
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.metrics.performance.JankStats
class MainActivity : ComponentActivity() {
private lateinit var jankStats: JankStats
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize JankStats listener for the Activity Window
jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) {
val durationMs = frameData.frameDurationUiNanos / 1_000_000L
val isFrozen = durationMs >= 700L
// Log or forward frame telemetry to your observability client
Log.w(
"JankStats",
"Jank detected: duration=${durationMs}ms, frozen=$isFrozen, states=${frameData.states}"
)
}
}
setContent {
AppNavigation()
}
}
override fun onResume() {
super.onResume()
jankStats.isTrackingEnabled = true
}
override fun onPause() {
super.onPause()
jankStats.isTrackingEnabled = false
}
}
frameData.isJank is determined dynamically by comparing the frame render duration against dynamic device refresh rate baselines calculated by the library.
Enriching Telemetry with PerformanceMetricsState
Knowing that a frame took 820ms is useless if your telemetry cannot answer: what was the app doing when the frame froze?
JankStats provides PerformanceMetricsState, allowing developers to attach dynamic, contextual key-value pairs to the current view hierarchy. When a frame drops, JankStats snapshots all active states into frameData.states.
In Jetpack Compose, access the state holder using rememberMetricsStateHolder():
package com.example.app.ui.components
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.metrics.performance.PerformanceMetricsState
import androidx.metrics.performance.rememberMetricsStateHolder
@Composable
fun ProductListScreen(
items: List<ProductItem>,
modifier: Modifier = Modifier
) {
val metricsHolder = rememberMetricsStateHolder()
val listState = rememberLazyListState()
// 1. Attach screen-level contextual state
DisposableEffect(metricsHolder) {
val state = metricsHolder.state
state?.putState("current_screen", "product_catalog")
state?.putState("item_count", items.size.toString())
onDispose {
state?.removeState("current_screen")
state?.removeState("item_count")
}
}
// 2. Attach dynamic scroll state during list interaction
LaunchedEffect(listState, metricsHolder) {
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
val state = metricsHolder.state
if (isScrolling) {
state?.putState("scroll_status", "scrolling")
} else {
state?.removeState("scroll_status")
}
}
}
LazyColumn(
state = listState,
modifier = modifier
) {
items(
items = items,
key = { it.id }
) { item ->
ProductRow(item = item)
}
}
}
When an 800ms frozen frame triggers, your JankStats listener output includes full operational context:
{
"durationMs": 812,
"isJank": true,
"isFrozen": true,
"states": [
{ "key": "current_screen", "value": "product_catalog" },
{ "key": "item_count", "value": "150" },
{ "key": "scroll_status", "value": "scrolling" }
]
}
Instead of an isolated number, engineering receives immediate clarity on where and when the stall occurred.
Optimization Tactics: Fixing 700ms+ Frozen Frames in Compose
A 700ms frame hang is rarely caused by standard rendering overhead. It indicates that the main thread was completely blocked. Here are the three primary causes in production Compose applications:
1. Synchronous Room or DataStore Reads During Recomposition
Reading disk-backed persistent storage directly within a composable body or remember block stalls the main UI thread during disk I/O.
Incorrect: Blocking the Main Thread During Recomposition
@Composable
fun UserBadge(userId: String, userDao: UserDao) {
// Blocks main thread for 100ms-800ms during SQLite disk access!
val user = remember(userId) {
userDao.getUserSync(userId)
}
Text(text = user?.name ?: "Loading...")
}
Correct: Offload to ViewModel and Collect Asynchronously
@Composable
fun UserBadge(viewModel: UserBadgeViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is UserUiState.Success -> Text(text = state.name)
is UserUiState.Loading -> CircularProgressIndicator()
}
}
// In ViewModel:
class UserBadgeViewModel(private val userDao: UserDao) : ViewModel() {
val uiState: StateFlow<UserUiState> = userDao.getUserFlow(userId)
.map { UserUiState.Success(it.name) }
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserUiState.Loading)
}
2. Complex Vector Parsing & Uncached Canvas Paths
Re-parsing complex SVG paths or creating Path and Paint allocations inside Modifier.drawWithContent or Canvas blocks forces garbage collection and CPU rendering freezes on every frame:
// Avoid: Allocating Path and Matrix inside drawing passes on every frame
Canvas(modifier = Modifier.fillMaxSize()) {
val path = Path().apply { /* complex dynamic SVG path calculation */ }
drawPath(path, color = Color.Red)
}
// Recommended: Cache drawing objects outside composition using drawWithCache
Modifier.drawWithCache {
val cachedPath = Path().apply { /* calculated once per size change */ }
onDrawBehind {
drawPath(cachedPath, color = Color.Red)
}
}
3. Subcomposition Overhead in Lazy Lists Without Stable Keys
When Lazy lists omit stable keys, small state mutations cause Compose to discard and recreate items rather than reordering them.
- Always provide stable keys: Use
key = { item.id }initems()calls. - Defer state reads: If a composable only needs to react to scrolling offsets, use lambda-based modifiers (
Modifier.offset { IntOffset(x, y) }) to skip recomposition and execute directly in the layout/draw phase. - Use
derivedStateOf: Wrap high-frequency scroll calculations to prevent triggering unnecessary recomposition passes across child nodes.
Correlating UI Jank with Session Context and User Impact
JankStats provides high-precision data on which frame froze and what contextual state was present via PerformanceMetricsState. However, in distributed production environments, knowing that a user was on product_catalog with scroll_status: scrolling still does not explain:
- What prior network calls or memory spikes led to the main-thread contention?
- Did an unhandled coroutine or disk lock earlier in the session precipitate the freeze?
- Did the user abandon their workflow or exit the funnel as a result of the freeze?
This is where Appxiom complements frame-level metrics by bridging runtime failures to complete user-journey observability:
- Chronological Activity Trails: When an ANR or application failure occurs, Appxiom reconstructs the user's preceding activity trail by capturing user taps, lifecycle events, and network responses leading up to the issue, removing guesswork when reproducing complex hangs.
- Goal Friction Impact (GFI): Appxiom measures whether performance freezes directly caused funnel abandonment (e.g., measuring cart drop-offs immediately following an ANR or frozen screen).
- Appxiom Quality Score (QS): Rather than treating crash and ANR rates in isolation, Appxiom factors overall application stability and user friction into a unified 0-10 release health index across your application versions.
Common Mistakes to Avoid
| Anti-Pattern | Operational Consequence | Recommended Fix |
|---|---|---|
| Initializing JankStats inside Composable functions | Recreated on every recomposition; duplicates tracking listeners. | Initialize once on Window in Activity onCreate(). |
Unbounded PerformanceMetricsState strings | High memory overhead; slows down frame metric dispatching. | Use concise identifiers (screen: home, not full JSON strings). |
Omitting removeState() in DisposableEffect | Stale state persists across subsequent screens, poisoning telemetry. | Always remove states inside onDispose {}. |
| Ignoring Baseline Profiles | JIT compilation pauses main thread during initial screen loads. | Ship automated Baseline Profiles to precompile Compose code. |
Best Practices Checklist
- Gate Releases by Frame Metrics: Benchmark frame rendering in CI/CD macrobenchmark runs to catch 700ms+ freezes before production rollout.
- Isolate Main-Thread Disk Access: Run StrictMode in debug builds to catch accidental I/O operations inside composable trees.
- Keep Context Lightweight: Limit
PerformanceMetricsStateto high-leverage dimensions: screen name, active scroll state, and data set size. - Correlate with User Journey Observability: Pair JankStats frame profiling with session-level observability like Appxiom to measure the downstream business and retention impact of unresponsiveness.
Conclusion
Frozen frames (>700ms) are not just visual blemishes; they are severe reliability defects that starve the main UI thread, degrade user trust, and act as a primary catalyst for user-perceived ANRs.
By implementing androidx.metrics:metrics-performance in Jetpack Compose, enriching frame telemetry with PerformanceMetricsState, and eliminating main-thread blocking operations, you can eliminate UI freezes, safeguard your Android Vitals, and maintain a responsive user experience.



