Skip to main content

2 posts tagged with "Frozen Frames"

View All Tags

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

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

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:

  1. 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.
  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). 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 } 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.

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:

  1. 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.
  2. 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).
  3. 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-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 Frame Metrics: Benchmark frame rendering in CI/CD macrobenchmark runs to catch 700ms+ freezes before production rollout.
  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: 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.

Why Mobile App Testers and Developers Should Use APM Tools for Performance Monitoring.

Published: · Last updated: · 2 min read
Appxiom Team
Mobile App Performance Experts

Performance monitoring and continues bug monitoring are critical parts of the Mobile App development lifecycle. As mobile devices become more powerful and users expect more from their apps, it is essential to ensure that apps are performing well and are free of bugs. One way to achieve this is by using Application Performance Management (APM) tools.

APM tools are designed to help mobile app testers and developers detect and diagnose performance issues and bugs in their apps. These tools provide a wide range of information about an app's performance, including memory usage, CPU usage, network activity, and more. This information can be used to identify bottlenecks, memory leaks, and other issues that can negatively impact an app's performance.

One of the main benefits of using APM tools is that they can help app developers and testers find and fix performance issues before they become a problem for users. By identifying issues early in the development process, teams can make changes to improve performance and ensure that the app is stable and reliable. This can help reduce the number of crashes and improve the overall user experience.

Another benefit of using APM tools is that they can help developers and testers understand how users are interacting with their apps. This can be especially useful for understanding how different user segments are interacting with the app, which can help teams optimize the user experience and make improvements that will have the biggest impact.

In short, APM tools are an essential tool for mobile app testers and developers. They help teams identify and fix performance issues and bugs, improve the user experience, and ensure that apps are stable and reliable. By using APM tools, teams can deliver better quality apps and create a more positive user experience.

Visit appxiom.com to know more about how Appxiom can help you with monitoring performance and bugs in mobile apps.