Skip to main content

3 posts tagged with "android-vitals"

View All Tags

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.