Skip to main content

4 posts tagged with "Android Vitals"

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 Your App and Website's Core Vitals Are Tanking Your Conversion Rate (and How to Measure the Real Revenue Loss)

Published: · 9 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

High conversion is a moving target on both web and mobile platforms. Even tiny delay - like a one-second pause before showing a website's checkout page or a micro-stutter during native card entry - can spike shopping cart abandonment. Traditional analytics tell you where users drop off. They rarely tell you why. That gap is exactly where digital revenue leaks.

This article translates obscure performance signals (frozen frames, slow renders, ANRs, API delays on mobile, and Core Web Vitals, LCP, INP, JavaScript errors on web) into business metrics you can act on. Armed with these metrics, you can quantify, communicate, and fix the conversion impact of digital performance issues.

  • Primary audience: Digital Product Managers, eCommerce Directors, Webmasters, and App Owners who need to justify performance budgets to executives.
  • Primary focus: Optimizing your mobile and web performance conversion rate by directly linking technical quality to revenue.

The silent conversion killer: small delays, big losses​

It is well-documented that page load and responsiveness directly impact user behavior:

Whether a user is browsing a web app on Chrome or using a native Android/iOS application, the psychological mechanism is identical: perceived performance drives trust, task completion, and conversion. Here is how UI lag affects sales across your web and mobile platforms:

  • Frozen frames and slow renders: Create "jank" on mobile apps, while high Interaction to Next Paint (INP) causes sluggish inputs on web browsers. Both break flow in critical conversion funnels (e.g., shipping selection, OTP verification).
  • API delays: Elongate high-friction moments (like payment authorization or address auto-complete), leading to second-guessing and cart abandonment.
  • Crashes, ANRs, and JavaScript errors: Halt the user journey entirely.

To justify engineering time and refactoring sprints, you must tie these performance issues directly to a business KPI: conversion rate, checkout completion, or revenue per user. That is the language that unlocks executive budget.

The telemetry gap: where analytics stop short​

Your analytics platform may show you the "what": sessions, clicks, pageviews, and drop-offs. But it rarely explains the "why" - was it a UI freeze, a network timeout, a JavaScript exception, or a device-specific ANR (Application Not Responding)?

  • Traditional web & product analytics (e.g., Google Analytics 4, Mixpanel) identify the funnel step with the highest abandonment, not the technical cause.
  • APM tools (Application Performance Monitoring) notify you when a backend endpoint is slow, but don't show whether that slowness actually blocked a high-value purchase.
  • Crash reporting tools catalog exceptions, but fail to show how many sales were lost because of those exceptions.

Core digital vitals are key to performance, but teams rarely connect them to business outcomes:

  • Mobile Vitals: Android defines "slow rendering" (frames taking >16ms) and "frozen frames" (frames taking >700ms), which correlate with visible stutter. iOS has similar metrics for App Hangs.
  • Core Web Vitals: Google measures Interaction to Next Paint (INP) to evaluate responsiveness and Largest Contentful Paint (LCP) to evaluate load speed. Poor ratings on these metrics correlate directly with lower search rankings and lower conversion.

Without a bridge from technical telemetry to goal completion, web and mobile performance improvements compete with new features - and usually lose.

Introducing Goal Friction Impact (GFI): quantifying the "why" behind conversion loss​

Appxiom's Goal Friction Impact (GFI) closes the telemetry gap by attributing user goal failure directly to technical causes. Instead of raw bug counts or isolated endpoint metrics, GFI maps crashes, ANRs, web Javascript errors, API bottlenecks, and rendering delays directly to conversion funnel failures.

What GFI measures for each user goal (e.g., signup, login, purchase):​

  1. Goal Attempts: Users who initiated a journey (e.g., clicked "Checkout" or "Register").
  2. Failed Attempts: User journeys that failed due to a technical error (e.g., JS exception on web, crash on app, checkout API timeout).
  3. Friction Drop-offs: Users who voluntarily abandoned the journey after encountering an issue (e.g., sluggish form fields due to poor INP or frozen frames).
  4. Affected Installations/Users: Unique visitors or devices impacted.

The GFI Score (0-10):​

  • 0–2 (Low Friction): Clean, high-performing user journey.
  • 3–5 (Moderate Friction): Noticeable delay/lag that suppresses conversion rates.
  • >5 (High Friction): Red flag; critical issues are actively blocking goal completion and draining revenue.

GFI vs. traditional tools: what changes in practice​

QuestionTraditional Analytics (GA4, Mixpanel)APM & Crash Tools (Datadog, Sentry, Firebase Crashlytics)GFI (Appxiom)
Primary FocusFunnel events and page pathsLatency, server resource usage, exceptionsUser goals mapped to business outcomes
What it ExplainsWhere users drop offWhich systems are slow/brokenWhy users failed a goal (mapped to specific technical issues)
Prioritization MethodBy funnel step volumeBy slow endpoints or crash rateBy failed goals and revenue impact

The Outcome: Engineering, marketing, and product teams align on a single backlog prioritized by conversion rate and revenue impact rather than arbitrary technical severity.

From friction to dollars: a model for ROI and budget justification​

To build a case for a performance budget or refactoring sprint, translate GFI signals into business at risk across both your web and mobile platforms.

Definitions per goal (e.g., "Purchase"):​

  • Attempts ($A$): Total attempts to complete the goal.
  • Failed attempts ($F$): Failures caused directly by technical errors.
  • Drop-offs after an issue ($D$): Abandonment after a responsiveness issues (slow INP or frozen frames).
  • Average Order Value ($AOV$)
  • Baseline Completion Rate ($CR_0$): Conversion rate when friction is low.

Key Formulas:​

  1. Friction Rate (FR) = (F + D) / A

  2. Friction-Attributed Failed Conversions (FAFC) = F + D

  3. Revenue at Risk (per period) = FAFC × AOV

  4. Conversion Lift Potential (Δ CR) ≈ FR × recovery rate (approximately)

Multi-Platform Scenario (Monthly Analysis):​

To show the true value of cross-platform monitoring, let's analyze web and mobile platforms side-by-side:

  • Web Platform (High Volume, Moderate AOV):

    • Total attempts (A_web) = 500,000 checkout attempts
    • Failed attempts (F_web) = 12,000 failed attempts (Javascript errors during submit, API timeouts)
    • Drop-offs (D_web) = 8,000 drop-offs (high INP causing sluggish fields, slow LCP)
    • Average Order Value (AOV_web) = $35
    • Web Revenue at Risk = (12,000 + 8,000) × $35 = $700,000/month
  • Mobile App Platform (Lower Volume, Higher AOV):

    • Total attempts (A_app) = 120,000 checkout attempts
    • Failed attempts (F_app) = 3,600 failed attempts (ANRs on payment gateway, crashes)
    • Drop-offs (D_app) = 2,400 drop-offs (frozen frames during shipping address input)
    • Average Order Value (AOV_app) = $48
    • App Revenue at Risk = (3,600 + 2,400) × $48 = $288,000/month
  • Combined Monthly Revenue at Risk: $988,000

Even recovering just 50% of these failures in your next sprint saves $494,000/month. This is how you measure revenue loss from app crashes and web errors credibly and conservatively.`

Appxiom’s GFI dashboard: the workflow that ties engineering to revenue​

  1. Select a goal to monitor: Choose standard events (signup, purchase, login) or configure custom goals tracking multi-platform journeys.
  2. Monitor visitor segments and GFI scores: Track your digital experience quality score across different browsers, operating systems, app versions, and geographies.
  3. Investigate specific issues: Pinpoint the technical cause of friction (e.g., a specific JavaScript bundle bloating INP on Chrome, or an iOS app hang in the shipping controller). Push these directly to Jira with one click.
  4. Track trend lines across releases: Verify if updates reduce friction or introduce new regressions.
  5. Quantify the business result: Compare GFI and revenue at risk before and after the hotfix to measure the direct ROI of your engineering sprint.

Best practices: connect mobile and web vitals to conversion like a pro​

  • Instrument journeys, not just pageviews: Define clear attempt, success, and failure criteria for key customer flows.
  • Set baseline alerts: Treat any GFI score above 5 as a critical event requiring immediate engineering inspection.
  • Triangulate issues: Combine crash logs/JS exceptions with web vitals (LCP, INP) and API network metrics.
  • Prioritize by business impact: Rank backlogs by FAFC × AOV, not by frequency alone.
  • Run rollouts with GFI guardrails: Monitor GFI on canary web releases or app updates to ensure new features don't tank conversion.
  • Communicate in business terms: Share weekly updates containing the overall GFI trend and revenue recovered rather than raw error rates.

Stakeholder impact: one score, shared accountability​

  • Developers: Fix the right bugs first using precise environment details (browser version, OS, device model, stack trace). Push tickets to Jira instantly.
  • QA Engineers: Validate releases by comparing GFI trends. Catch silent web hangs or app freezes before they hit 100% of traffic.
  • Product Managers: Predict revenue at risk, justify engineering sprints, and maintain an evidence-based roadmap.
  • Executives: Keep tabs on a single, credible digital experience quality score that links engineering health directly to business growth.

Common question on GFI​

  • What is GFI? Goal Friction Impact is a 0–10 metric measuring how technical friction (crashes, JS errors, API lag, UI slowness) blocks user conversion.
  • Which platforms are supported? Appxiom supports web (desktop and mobile browsers) as well as native mobile platforms (iOS, Android, and Flutter).
  • What is a "good" GFI score? Aim for 0–2. A score above 5 represents high friction and requires immediate action.

Conclusion​

When performance issues are divorced from business metrics, performance tasks will always lose to features. With Goal Friction Impact, you can quantify exactly how UI lag, crashes, and sluggish vitals suppress your mobile and web performance conversion rate - allowing you to prioritize the highest-ROI fixes instantly.

Next steps:

Automated Performance Hygiene: Integrating Baseline Profiles and Macrobenchmarks in CI/CD

Published: · 11 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Modern Android apps don't just need to work; they need to feel fast on first launch and stay smooth as users scroll and navigate. The hard part is making that performance repeatable and enforceable across releases and devices. In this post, we'll wire up an end-to-end, production-grade workflow that does exactly that: generate and ship Baseline Profiles to improve startup/jank, and run Macrobenchmarks in CI to catch regressions before they reach users.

You'll leave with:

  • A working multi-module setup for Baseline Profiles and Macrobenchmarks
  • Deterministic CI execution using Gradle Managed Devices
  • Practical tests for cold startup and jank
  • Real-world guardrails, pitfalls, and troubleshooting tips

Prerequisites and versions​

  • Android Studio Ladybug (2024.2.x) or newer
  • JDK 17
  • Gradle 8.6+
  • AGP 8.5+
  • Kotlin 2.0+
  • Min SDK: 21+ (app); Test device API: 29+ (Macrobenchmark/MVD)
  • Dependencies (use latest stable in your project; versions below are known stable as of late 2024):
    • androidx.profileinstaller:profileinstaller:1.3.1
    • androidx.benchmark:benchmark-macro-junit4:1.2.4
    • androidx.test.uiautomator:uiautomator:2.3.0
    • androidx.test.ext:junit:1.1.5
    • androidx.test:runner:1.5.2
    • Baseline Profile Gradle plugin: androidx.baselineprofile:baselineprofile-gradle-plugin:1.2.4

Note: Always prefer the latest stable artifacts from developer.android.com/jetpack/androidx/releases to benefit from fixes and device compatibility improvements.

Why this matters​

  • Baseline Profiles speed up your app by precompiling the hot code paths the first time the app is installed or updated. The result: significantly faster cold start and reduced jank right from v1 of a release.
  • Macrobenchmarks measure real app performance (startup time, frame timing) on-device. They are your regression safety net.
  • CI integration turns performance into a non-negotiable quality gate, not an afterthought.

We'll integrate both so every PR is validated against real-world performance - and we'll automatically produce and ship Baseline Profiles as part of your release pipeline.

What we'll build​

We'll use a three-module layout:

  • app - your production app (Compose or Views)
  • baselineprofile - instrumentation tests that generate Baseline Profiles
  • macrobenchmark - instrumentation tests that measure startup and jank

We'll also configure Gradle Managed Devices (MVD) to run both test suites in a reproducible emulator and wire CI to:

  • Generate and update baseline-prof.txt for the app
  • Run startup and frame-timing benchmarks
  • Fail early if performance is broken

Step 1: Add ProfileInstaller to your app module​

ProfileInstaller ships and installs your baseline-prof at app startup. Add it to app/build.gradle.kts:

plugins {
id("com.android.application")
kotlin("android")
}

android {
namespace = "com.example.app"
compileSdk = 35

defaultConfig {
applicationId = "com.example.app"
minSdk = 21
targetSdk = 35
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
// Keep debuggable true. Macrobench will never target debug anyway.
}
}
}

dependencies {
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
// Usual app deps (Compose, etc.)
}

Tip:

  • ProfileInstaller has consumer ProGuard rules; you usually don't need custom keep rules. If you use exotic shrinker configs, verify the content provider is not stripped.

Step 2: Create a Baseline Profile producer module​

Add a new module "baselineprofile" of type "com.android.test". These instrumentation tests launch your app, execute critical user flows, and output a baseline profile.

baselineprofile/build.gradle.kts:

plugins {
id("com.android.test")
kotlin("android")
// Baseline Profile Gradle Plugin - wires profile generation + copy
id("androidx.baselineprofile") version "1.2.4"
}

android {
namespace = "com.example.app.baselineprofile"
compileSdk = 35

defaultConfig {
minSdk = 29 // required for Macrobenchmark/Baseline profile generation device
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Target the app under test
targetProjectPath = ":app"
}

// Gradle Managed Devices for deterministic execution
testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "google_apis" // Prefer google_apis for realistic perf
}
}
}
animationsDisabled = true
}

buildTypes {
// Generate profiles against release variant because that's what you ship
create("release")
}
}

dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.4")
implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test:runner:1.5.2")
implementation("androidx.test.uiautomator:uiautomator:2.3.0")
}

Baseline profile producer test (Kotlin):

package com.example.app.baselineprofile

import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

private const val TARGET_PACKAGE = "com.example.app"

@RunWith(AndroidJUnit4::class)
class GenerateBaselineProfile {
@get:Rule
val rule = BaselineProfileRule()

@Test
fun generate() = rule.collect(
packageName = TARGET_PACKAGE,
// Run with release to collect a representative profile
includeInStartupProfile = true
) {
// 1) Cold start the default Activity
startActivityAndWait()

// 2) Execute your hot paths. Keep flows representative and deterministic.
// Avoid sleeps; prefer waiting for idle or explicit conditions.

// Example: navigate to Home -> Search -> Details and scroll
device.waitForIdle()
// Use UiAutomator or Compose test tags to interact reliably
// device.findObject(By.res(TARGET_PACKAGE, "search")).click()
// device.findObject(By.res(TARGET_PACKAGE, "query")).text = "kotlin"
// device.pressEnter()
// device.findObject(By.res(TARGET_PACKAGE, "result_0")).click()
// device.swipe(x1, y1, x2, y2, steps)
}
}

Notes:

  • Keep this test focused on your most common warm paths (first-screen rendering + a couple of hot interactions).
  • Prefer stable selectors (resource IDs or test tags) over text-based matches.
  • Avoid randomness; performance tests must be deterministic.

Running it locally (managed device will be created and torn down automatically):

./gradlew :baselineprofile:pixel6Api31ReleaseAndroidTest

If you use the baseline profile plugin's task to orchestrate and copy into the app module, run:

./gradlew :baselineprofile:generateBaselineProfile

After generation, the plugin writes or updates baseline-prof.txt in your app:

  • app/src/main/baseline-prof.txt (or per-flavor e.g. app/src/freeRelease/baseline-prof.txt)

Commit this file, just like ProGuard mappings or codegen outputs you curate.

Step 3: Add a Macrobenchmark module​

This module contains startup and jank benchmarks. It targets your app's release variant and runs on API 29+ devices.

macrobenchmark/build.gradle.kts:

plugins {
id("com.android.test")
kotlin("android")
}

android {
namespace = "com.example.app.macrobenchmark"
compileSdk = 35

defaultConfig {
minSdk = 29
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
targetProjectPath = ":app"
}

testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "google_apis"
}
}
}
animationsDisabled = true
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}

// Ensure we measure release
buildTypes {
create("benchmark")
// Map benchmark to release app if needed:
// variantFilter { if (name != "benchmark") setIgnore(true) }
}
}

dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.4")
implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test:runner:1.5.2")
implementation("androidx.test.uiautomator:uiautomator:2.3.0")
androidTestUtil("androidx.test:orchestrator:1.4.2")
}

Startup benchmark:

package com.example.app.macrobenchmark

import androidx.benchmark.macro.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

private const val TARGET_PACKAGE = "com.example.app"

@RunWith(AndroidJUnit4::class)
class StartupBenchmarks {

@get:Rule
val benchmarkRule = MacrobenchmarkRule()

// Cold startup with Baseline Profile required - fails if baseline is missing.
@Test
fun coldStartup_withBaselineProfile() = benchmarkRule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require
)
) {
pressHome()
startActivityAndWait()
}

// Compare a worst-case fallback (no pre-compilation)
@Test
fun coldStartup_noCompilation() = benchmarkRule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.None()
) {
pressHome()
startActivityAndWait()
}
}

Jank/frame timing benchmark:

@RunWith(AndroidJUnit4::class)
class ScrollBenchmarks {

@get:Rule
val benchmarkRule = MacrobenchmarkRule()

@Test
fun homeFeed_scroll() = benchmarkRule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = listOf(FrameTimingMetric()),
iterations = 5,
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.UseIfAvailable
),
setupBlock = {
killProcess()
startActivityAndWait()
}
) {
// Exercise a realistic scroll on your feed/list screen
// Example using UiAutomator (replace with your IDs/test tags):
// val recycler = device.findObject(By.res(TARGET_PACKAGE, "home_list"))
// recycler.setGestureMargin(device.displayWidth / 10)
// repeat(8) { recycler.fling(Direction.DOWN) }
// repeat(2) { recycler.fling(Direction.UP) }
}
}

Run locally:

./gradlew :macrobenchmark:pixel6Api31BenchmarkAndroidTest

You'll find Perfetto traces and JSON summaries under macrobenchmark/build/outputs/... for analysis.

Step 4: Deterministic devices with Gradle Managed Devices​

Use MVD across both modules so CI can spin up the exact same emulator configuration every time. Key practices:

  • Prefer "google_apis" images for more realistic performance counters.
  • Disable animations (we used animationsDisabled = true).
  • Pin one device model and API for consistency (e.g., Pixel 6 @ API 31).
  • Keep tests short and deterministic to avoid thermal or scheduling drift.

Step 5: CI wiring (GitHub Actions example)​

This workflow:

  • Builds the app release
  • Generates/updates Baseline Profiles
  • Runs Macrobenchmarks
  • Uploads benchmark artifacts

.github/workflows/perf.yml:

name: Performance hygiene

on:
pull_request:
push:
branches: [ main ]

jobs:
perf:
runs-on: ubuntu-22.04

steps:
- uses: actions/checkout@v4

- name: Setup JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: gradle

- name: Gradle info
run: ./gradlew --version

- name: Assemble release
run: ./gradlew :app:assembleRelease

# Generate and copy baseline-prof.txt into app/ (via plugin)
- name: Generate Baseline Profile
run: ./gradlew :baselineprofile:generateBaselineProfile --no-daemon --stacktrace

- name: Validate app contains Baseline Profile
run: |
test -f app/src/main/baseline-prof.txt || (echo "Missing baseline-prof.txt"; exit 1)
wc -l app/src/main/baseline-prof.txt

# Run macrobenchmarks on the same managed device
- name: Run Macrobenchmarks
run: ./gradlew :macrobenchmark:pixel6Api31BenchmarkAndroidTest --no-daemon --stacktrace

- name: Upload benchmark outputs
uses: actions/upload-artifact@v4
with:
name: macrobenchmark-results
path: macrobenchmark/build/outputs/**

Notes:

  • The baseline profile plugin orchestrates tests and copies the profile file for you. If you prefer manual control, run the instrumentation test task and copy the generated baseline from the module's outputs to app/src/main/baseline-prof.txt.
  • For gating on metric thresholds, see the next section.

Step 6: Gating on thresholds​

Macrobenchmark currently reports results as files. A pragmatic approach is to parse its JSON summary in CI and fail the job when a metric exceeds your threshold. The file path can vary by AGP and device; look for summary JSON files under:

  • macrobenchmark/build/outputs/managed_device_android_test/...
  • macrobenchmark/build/outputs/androidTest-results/connected/...
  • macrobenchmark/build/outputs/macrobenchmark/...

A simple gating step using jq might look like:

# Example path; adjust to your project's output
SUMMARY=$(ls macrobenchmark/build/outputs/**/macrobenchmark-*.json | head -n 1)

echo "Reading metrics from: $SUMMARY"

COLD_P50_MS=$(jq '.benchmarks[] | select(.name=="StartupBenchmarks_coldStartup_withBaselineProfile") | .metrics[] | select(.name=="startupMs") | .medianNs' "$SUMMARY" | awk '{printf "%.0f\n", $1/1000000}')

echo "cold-start p50: ${COLD_P50_MS}ms"
if [ "$COLD_P50_MS" -gt 900 ]; then
echo "Regression: cold-start p50 > 900ms"
exit 1
fi

Recommendations:

  • Gate on medians or p90, not max.
  • Reserve headroom for CI noise. Start generous (e.g., p50 < 1000ms) and tighten later.
  • Record a weekly rolling baseline to monitor drift.

Step 7: Developer workflow​

  • Run profile generation locally before cutting a release:
./gradlew :baselineprofile:generateBaselineProfile
git add app/src/main/baseline-prof.txt
  • Validate macrobench quickly:
./gradlew :macrobenchmark:pixel6Api31BenchmarkAndroidTest -Pandroid.experimental.testOptions.managedDevices.emulator=true
  • If you introduce a new hot screen, update the profile producer test to exercise it.

Best practices and implementation notes​

  • Keep profile flows short and representative: launch, first frame, one or two critical navigations, and a simple scroll. Overly long or random flows produce noisy or brittle profiles.
  • Target release builds for generation and measurement. Macrobenchmarks against debug variants are misleading.
  • Use BaselineProfileMode.Require for at least one startup benchmark to ensure the shipped artifact contains a valid baseline. This will fail fast if the profile wasn't packaged.
  • Compose apps: Prefer stable test tags and semantic actions. Avoid find-by-text for localization robustness.
  • Variants/flavors: Baseline profiles can be flavor-specific. Place them under src/<flavor><BuildType>/baseline-prof.txt when necessary.
  • Dynamic Feature Modules: Generate profiles that traverse into the feature. Include baseline profiles in the feature module as well if it ships separately.
  • ART profile verification: After assembleRelease, inspect intermediates to ensure your baseline is packaged (e.g., app/build/intermediates/art-profile/release/ or use the plugin's verify tasks if available).
  • Device stability:
    • Prefer one managed device configuration.
    • Ensure the runner disables animations.
    • Keep iteration counts modest to reduce thermal drift in CI.

Common issues and troubleshooting​

  • The Baseline Profile file didn't show up in the app module

    • Ensure the Baseline Profile Gradle Plugin is applied in the producer module and you executed its generate task.
    • Verify defaultConfig.targetProjectPath = ":app" in the producer module.
    • Check logs for "baseline-prof.txt" copy output; failing UI selectors may cause an empty profile.
  • "BaselineProfileMode.Require" test fails

    • This means the app under test didn't include a baseline. Confirm app/src/main/baseline-prof.txt is present and that you're benchmarking the release variant. Rebuild the release and rerun tests.
  • Macrobenchmark cannot find device or fails to boot

    • Use managed devices, not an arbitrary emulator started elsewhere.
    • Use a "google_apis" image. ATD images can lack some performance counters.
    • On CI, give the emulator time to boot; Gradle MVD handles this, but large images can still timeout if network is slow - bump Gradle's test timeouts if needed.
  • Flaky UiAutomator selectors

    • Prefer resource IDs or Compose test tags. Wait for idle or specific view conditions instead of Thread.sleep.
  • ProGuard/R8 stripping ProfileInstaller

    • Rare with modern versions. If in doubt, confirm the content provider exists via aapt dump badging on the release APK/AAB.
  • "App startup is fast locally but slow in CI"

    • CI VMs are noisy. Gate on medians/p90 with safe headroom and keep device configuration stable.
    • Do not run resource-heavy jobs on the same runner concurrently.

What good looks like in production​

  • Baseline Profiles generated on every PR that touches hot paths; updated profiles are committed to main.
  • Macrobenchmarks run on a stable managed device per PR and nightly on a second API level (e.g., API 31 and 34).
  • CI gates prevent merges if:
    • BaselineProfiles are missing (Require mode fails),
    • Startup median exceeds your agreed threshold,
    • p90 frame time degrades past a tolerance band.
  • Perfetto traces are uploaded as artifacts for debugging occasional regressions.

Key takeaways​

  • Baseline Profiles and Macrobenchmarks complement each other: one accelerates the app your users get, the other keeps you honest in CI.
  • Treat performance like tests: deterministic devices, representative flows, and clear thresholds.
  • Automate the boring parts. Let Gradle Managed Devices and the Baseline Profile plugin do the heavy lifting.
  • Keep your baseline-prof.txt under version control and evolve it as your app's hot paths change.

Next steps:

  • Add a second managed device (API 34, arm64) and compare results weekly.
  • Split your macrobench suite into fast (PR) and deep (nightly) runs.
  • Track trends by exporting metrics to your observability stack or a lightweight dashboard.

Ship fast - and stay fast.

Using Android Vitals Metrics to Predict and Prevent Application Not Responding (ANR) Events

Published: · 6 min read
Appxiom Team
Mobile App Performance Experts

The Subtle Onset of an App-Numbing Outage​

It usually begins as a faint uptick - a few ANR entries trickling into your Play Console. Dismissed initially as the cost of doing business ("There's always a background process hiccup, right?"), that number swells. By the next release, what was once an edge case now plots as a trend: churned users citing frozen screens, unresponsive tabs, rapid uninstall rates.

These moments, for a senior Android engineer, are never just about chasing an elusive stack trace. They’re lessons in understanding - the difference between reading numbers and reading what the numbers reveal about your systemic weaknesses.

From Metrics to Meaning: What Android Vitals Is Telling You​

A mistake many teams make is treating Android Vitals as a passive dashboard - something to be checked post-mortem. But, in reality, Vitals is a living telemetry stream, a mirror for app health at scale. Each ANR metric is woven out of user experience: main thread stalls, excessive broadcast receiver work, read/write blocks.

Consider this excerpt from a Play Console telemetry snapshot:

ANR rate: 0.57% (90th percentile)
Highest correlation: BackgroundService Execution Time (p95: 6.2s)
Other signals: InputDispatching Timeout, ForegroundLaunch Delays

At first, the temptation is to dive straight into the most frequent offender in your logs. But this pulls you into a whack-a-mole game. Instead, experienced engineers look for patterns. For example:

  • Do ANRs cluster on particular device models, OS versions, or network conditions?
  • Are spikes correlated with long I/O traces on the main thread?
  • Is there a recurring background service or broadcast coinciding with user-initiated freezes?

The art is shifting from asking "Where did things go wrong?" to "What systemic stressors are manifesting in these metrics?"

A Real-World Failure: The Invisible Slowdown​

Let’s ground this: Suppose, during a peak release, user complaints cite “tapping buttons does nothing,” but crash logs are oddly silent. You pull Android Vitals and find a hike in InputDispatchingTimeout ANRs. Checking logs like:

com.example.app ANR in com.example.app
Reason: Input dispatching timed out (Activity com.example.app.MainActivity)
Load: 1.25 / 1.09 / 1.00
CPU usage: 74% (user 52%, system 22%)

There’s no null pointer or crash - just a main thread suffocating, often because an innocent UI event triggered a heavy database migration or a sync operation on the UI thread.

The root cause? A subtle misconception: "If it’s a quick DB read, it’s fine on the main thread." Until, of course, it isn't - on slower devices or busy CPU cycles, that “quick” read can easily breach the 5-second input timeout.

The fix isn't just in refactoring that specific query off the main thread, but in systematizing a rule: All I/O, all DB reads, disk writes, and network checks should be main-thread forbidden, enforced via static analysis (like Android Lint rules) and with real-world spot checks using traces.

Beyond Symptoms: Proactive ANR Forecasting​

ANRs are notoriously reactive: once they’re happening, user harm is done. The real challenge is investing in predictive signals.

A practical strategy: leverage the combination of Vitals percentile metrics and custom telemetry to catch suspects before the ANR threshold. For instance, by instrumenting key latency points:

val start = SystemClock.elapsedRealtime()
val result = doNetworkOrDiskOperation()
val duration = SystemClock.elapsedRealtime() - start

if (duration > 200) {
FirebasePerformance.logCustomMetric("heavy_operation", duration)
}

Now, correlate these custom metrics with Play Console’s “Slow rendering” or “Cold start” warnings. When you see rising tail latencies edging closer to ANR cutoffs (e.g., routine ops flirting with >4s), you have both macro-signals (Vitals) and micro-insights (bespoke metrics) to target.

Trade-off: Instrumentation adds some overhead and telemetry bloat, so target high-risk paths - not every single method.

Pitfalls of Focusing Solely on the Stack Trace​

It's a rite of passage to over-index on the ANR stack traces Android provides:

"main" prio=5 tid=1 Native
| group="main" sCount=1 dsCount=0 obj=0x746f9bd0 self=0x7f8e21c000
| sysTid=13461 nice=-10 cgrp=default sched=0/0 handle=0x7f9871d4f8
at java.lang.Thread.sleep(Native Method)
at com.example.app.util.SyncHelper$job$1.run(SyncHelper.kt:42)

But the stack trace is less a cause, more a snapshot - a Polaroid of catastrophe at its peak. Deep problems - like resource contention, lock inversions, or dogpiled async work - unfold over seconds and aren't always represented here.

Smart teams use traces as starting points, but synthesize with:

  • System traces: Systrace or Perfetto logs reveal if main thread is starved for CPU due to background hogs (e.g., a foreground service spiking CPU).
  • ANR clustering: Are these traces frequent only on low-memory devices? Only after certain user flows?

Holistic ANR prevention comes from framing stack traces as symptoms within a broader system signature.

Strategies in Production: Mitigations and Feedback Loops​

Let’s reimagine response not as a one-time fix, but as a virtuous feedback cycle.

1. Instrument and Alert: Inject custom latency metrics at high-risk operations (I/O, startup path, navigation transitions), aggregating to your observability platform. Set up alerts when operations flirt with your threshold, even if no ANR yet occurs.

2. Vitals-Driven Release Gates: Institute Play Console metrics as a release blocker - e.g., block rolling out to 100% if ANR rate breaches 0.5% in staggered rollouts.

3. Real User Monitoring: For large user bases, some behaviors can only be seen at scale. Integrate tools like Firebase Performance or Appxiom UX to overlay user session data and see the contextual triggers that diagnostics miss.

Connecting the Dots: System Signals You Should Be Watching​

It’s tempting to rely solely on crash- or ANR-specific signals - but application responsiveness is a living, interdependent system.

What to watch:

  • ANR Rate (in Play Console): Overall health indicator
  • Slow Rendering/Startup > 5s: Early predictors of trouble brewing
  • RAM Usage and GC Spikes: Persistent memory churn raises stalls
  • Custom Async Operation Latency: Surface operations risking main thread waits

And crucially: connect these via dashboards - e.g., overlay ANR rate with percentile latencies from your own telemetry.

Example composite graph:

| Time        | ANR Rate | P95 I/O Latency | GC Pause/Min | Slow Startup Rate |
|-------------|----------|-----------------|--------------|------------------|
| 09:00-10:00 | 0.28% | 900ms | 180ms | 4.2% |
| 10:00-11:00 | 0.61% | 4,130ms | 410ms | 13.7% |

Notice that as P95 latency climbs, so does ANR rate - the canary singing long before disaster.

Evolving from Fixes to Resilience​

What transforms a team from firefighting ANRs to engineering resilience? It’s the shift to thinking in terms of lead indicators. Vitals offers the forest; traces and custom telemetry map the trees.

Mitigation flows from proactive usage: blocking synchronous I/O, abuse-proofing background work, and making Play Console ANR stats as central to your workflow as CI tests. Even the best code reviews miss concurrency bugs that only real users exposed at scale.

Every ANR investigated is both a post-mortem and a guide - if you let the system’s metrics teach you. The payoff isn’t just green dashboards, but apps that feel snappy and trustworthy to millions - because you learned to listen before they started to freeze.