Skip to main content

2 posts tagged with "Performance"

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.

Leveraging Flutter DevTools for Real-Time Performance Bottleneck Analysis

Published: · Last updated: · 6 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Performance issues in mobile apps don’t just annoy users-they drive abandonment, spark negative reviews, and make life miserable for developers on-call. Whether you’re building a new feature or tracking down a subtle lag that only appears on certain hardware, Flutter DevTools offers essential capabilities to spot and resolve real-world performance problems. In this post, we’ll go deep on how to leverage Flutter DevTools for real-time performance bottleneck analysis-empowering mobile developers, QA engineers, and engineering leads to debug faster, observe more effectively, and ship reliable apps with confidence.


Introduction: Why Proactive Performance Matters​

Modern users expect smooth, responsive, and visually appealing mobile apps. Even the most feature-rich product will be judged harshly if it stutters, janks, or crashes during basic interactions. As engineering teams, we have to move beyond reactive bug-fixing to proactive observability and continuous performance management.

Core Objectives for This Guide:

  • Identify real-world sources of Flutter app lag and inefficiency using Flutter DevTools
  • Demonstrate practical debugging patterns and performance analysis flows
  • Unlock actionable strategies to boost reliability and observability

We'll tackle these points step-by-step, anchoring discussion in realistic scenarios and supplying direct code and workflow snippets to up-level your Flutter debugging game.


Understanding Flutter Performance Issues: What Can Go Wrong?​

Unlike native SDKs, Flutter’s rendering is managed by a custom engine layered on Dart’s VM. This architecture is powerful but introduces unique challenges:

  • Janky UI: Frames take longer than 16ms (60FPS) to render, causing visible animation hitches.
  • Memory Leaks: Widgets or objects are inadvertently retained.
  • Slow Build/Render: Expensive rebuilds of widget trees triggered by naive state management.
  • Unoptimized Network/IO: Main isolate blocked by synchronous tasks.

These issues often show up as user-facing slowdowns-sometimes only under load, on specific hardware, or amidst tricky app state. That’s where real-time observability comes in.


Real-Time Profiling with Flutter DevTools​

Flutter DevTools is more than an inspector-it’s a real-time performance profiler and analytics suite. Let’s break down its most potent features for root-cause analysis:

1. Performance Tab: Frame Rendering at a Glance​

When users experience "jank," your first stop should be the Performance Tab. This visualizes frame rendering as a timeline-each vertical bar represents a frame.

How To Use:

  • Open your app in debug or profile mode (flutter run --profile).
  • Connect DevTools to the running app.
  • Interact with the slow section of your app.
  • Check for red vertical bars: these indicate missed frame deadlines.

Actionable Debugging:

  • Expand slow frames to see both UI (build/layout/paint) and raster (GPU) operations.
  • Look for spikes-excessive widget rebuilds, unnecessary repaints, or long-running logic.
  • Use the call stack ("stack frames") to determine which widgets/methods consume the most time.

Example: Diagnosing an Expensive Rebuild

Suppose list scrolling becomes laggy. After recording a session in DevTools:

ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
// Expensive widget tree.
return ComplexListTile(item: items[index]);
},
)

DevTools reveals repeated rebuilds of ComplexListTile. Solution: introduce a const constructor or extract static data outside the builder.

2. CPU Profiler: Pinpointing the Hot Paths​

For complex issues-like slow async data loads or background processing-the CPU Profiler is invaluable.

How To Use:

  • Trigger application flow (e.g., load a heavy screen).
  • Start CPU profiling in DevTools.
  • Stop after issue occurs; inspect the “Time Profiler” flame chart.

Use Cases:

  • Identify synchronous CPU-bound methods (string parsing, image decoding) running on the UI isolate.
  • Reveal expensive loops or function calls that block UI updates.

Actionable Tip:

  • Offload heavy work to compute() or background isolates.
Future<void> processHeavyData() async {
final result = await compute(parseLargeJson, rawJsonString);
setState(() {
parsedData = result;
});
}

Effective Debugging Strategies: Patterns That Work​

It’s not just about the tool-it’s about how you wield it. Here’s how veteran Flutter engineers approach performance debugging:

Proactive Observability​

  • Instrument your code with custom Timeline Events

    Timeline.startSync('Expensive Op');
    // ... code ...
    Timeline.finishSync();

    These annotations appear in the DevTools Performance timeline, making it easy to cross-reference logic with performance spikes.

  • Leverage Widget Inspector Identify unnecessary rebuilds by tracking Widget tree changes interactively.

Hot Reload vs. Hot Restart​

  • Prefer Hot Reload for day-to-day UI tweaking; however, always Hot Restart or cold restart for accurate performance traces, as lingering app state or memory leaks may not be cleaned up otherwise.

Automated Performance Regression Testing​

  • Use flutter drive and CI/Docker-based device farms to collect performance metrics on every pull request.
  • Store and visualize timeline traces over time-catch regressions before release.

Reliability Through Deep Observability: Beyond DevTools​

Even the best profiler is only a piece of your observability puzzle. For true reliability, combine DevTools insights with production-level monitoring:

  • Integrate Crashlytics/Sentry to catch issues that only appear in the wild.
  • Add in-app performance logging-send custom metrics from key workflows to a backend.
  • Monitor memory and resource utilization: The Memory tab in DevTools can help spot leaks, but also add guards in production.

Example: Guarding Against Memory Leaks Track object allocation over time before/after navigation:

WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('Widget tree size: ${context.widget.toString()}');
});

Tip: If object counts continually increase with navigation, you have a retention issue.


Engineering Leadership Perspective: Empowering Teams​

For engineering leaders, the impact is twofold:

  • Process Suggestions:
    • Make performance profiling part of your release checklist.
    • Hold regular “profiling guild” meetings to share findings and anti-patterns.
  • Education:
    • Codify best practices (e.g., avoid rebuilding complex widgets unnecessarily).
    • Encourage a “performance is everyone’s job” culture-QA and developers both monitor the perf dashboard.

Conclusion: Ship Faster, Smoother, More Reliable Apps​

Flutter DevTools transforms performance debugging from guesswork into a science. By mastering its real-time profiling features-and integrating actionable observability into your workflow-your team can:

  • Identify and resolve performance bottlenecks early and efficiently.
  • Build a culture of proactive debugging and reliability.
  • Respond to user issues with concrete data (not just intuition).

Next steps: Schedule a “profiling hour” on your next sprint, instrument key screens, and empower your entire team to become app performance champions.

Have a specific performance challenge? Share your war stories (and wins) in the comments-we’re building this mobile community together!