Skip to main content

Tracking Frozen Frames (>700ms) and UI Jank in Compose Using JankStats and Android Vitals

Published: · 9 min read
Don Peter
Cofounder and CTO, Appxiom

Few issues damage an Android app's growth faster than UI jank. A user flings 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 Play treats this severity with algorithmic urgency through Android Vitals. When an app crosses the Frozen Frames (>700ms) bad behavior threshold, Google Play actively reduces its store discovery, strips it from search recommendations, and surfaces warning notices on the store listing.

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 and APM 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 store metrics.

The Android Vitals Penalty: Why 700ms Frames Break Discovery​

Google Play continuously collects system rendering metrics across your active production install base via Android Vitals. It evaluates rendering stability across two thresholds:

  1. Slow Frames (>16ms): Frames that miss the hardware vsync interval (exceeding ~16.6ms on 60Hz displays or ~8.3ms on 120Hz panels). The Play Console flags an app when more than 50% of daily sessions experience slow frames.
  2. 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.

The Business Impact of Frozen Frames​

While slow frames cause perceptible stutter, frozen frames represent complete user journey breakdown.

The Google Play Bad Behavior Threshold for frozen frames is strictly set at 0.10% of sessions (where more than 0.10% of sessions experience at least one frame taking longer than 700ms to render). You can review the official requirements in the Android Vitals documentation. If more than 1 in 1,000 sessions encounters a 700ms+ freeze on core device cohorts:

  • Your app is disqualified from Google Play promotional placement and "Featured" categories.
  • Play Store organic search rankings drop, raising customer acquisition costs (CAC).
  • A warning banner appears on your store page: "Recent data shows this app may stop responding on similar devices."

Fixing frozen frames is not merely an engineering cleanup task; it is critical store revenue protection.

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:

// ❌ 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)
}

// ✅ 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 } in items() 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.

Connecting Production JankStats to Appxiom Activity Trails​

JankStats provides high-precision data on which frame froze and what contextual state was present. 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 introduced memory bloat?
  • Did an API error earlier in the session cause uncollected coroutines?
  • How many users abandoned their purchase after this freeze?

This is where Appxiom bridges frame metrics to user impact:

  1. Chronological Activity Trails: Appxiom links each JankStats event with the user's preceding activity trail - capturing background network responses, state mutations, and screen navigation history before the 700ms hang occurred.
  2. Goal Friction Impact (GFI): Appxiom measures whether the frozen frame directly caused funnel abandonment (e.g., measuring cart drop-offs immediately following an ANR or frozen screen).
  3. Appxiom Quality Score (QS): Rather than isolating Play Vitals in the Play Console, Appxiom factors frozen frames into a unified 0–10 release health index across your application versions.

Common Mistakes to Avoid​

Anti-PatternOperational ConsequenceRecommended Fix
Initializing JankStats inside Composable functionsRecreated on every recomposition; duplicates tracking listeners.Initialize once on Window in Activity onCreate().
Unbounded PerformanceMetricsState stringsHigh memory overhead; slows down frame metric dispatching.Use concise identifiers (screen: home, not full JSON strings).
Omitting removeState() in DisposableEffectStale state persists across subsequent screens, poisoning telemetry.Always remove states inside onDispose {}.
Ignoring Baseline ProfilesJIT compilation pauses main thread during initial screen loads.Ship automated Baseline Profiles to precompile Compose code.

Best Practices Checklist​

  1. Gate Releases by Frozen Frames: Establish CI/CD alerting if frozen frame sessions exceed 0.05% in internal test tracks.
  2. Isolate Main-Thread Disk Access: Run StrictMode in debug builds to catch accidental I/O operations inside composable trees.
  3. Keep Context Lightweight: Limit PerformanceMetricsState to high-leverage dimensions: screen name, active scroll state, and data set size.
  4. Correlate with User Journey Observability: Connect JankStats with Appxiom to measure the real business and conversion impact of UI freezes.

Conclusion​

Frozen frames (>700ms) are not just visual blemishes - they are severe reliability defects that Google Play penalizes with search deprioritization and store warnings.

By implementing androidx.metrics:metrics-performance in Jetpack Compose, enriching frame data with PerformanceMetricsState, and diagnosing root causes with Appxiom Activity Trails, you can eliminate main-thread freezes and safeguard your app's store visibility and user retention.