| name | ux-metrics |
| description | Measure mobile UX quality with frozen frames, slow frames, ANRs, app hangs, and interaction-to-next-paint style metrics. Use when diagnosing jank, adopting JankStats / MetricKit, or reporting UX health. |
UX Performance Metrics on Mobile
Instructions
Startup time is only part of the story. UX quality is dominated by jank, frozen frames, ANRs (Android) and app hangs (iOS). Measure them directly on the device and roll them up into release-over-release dashboards.
1. Definitions
- Slow frame: a frame that took longer than the target frame interval. Baseline 16 ms at 60 Hz, 8.3 ms at 120 Hz.
- Frozen frame: a frame that took > 700 ms (Android), > 0.7 s (iOS). Always user-noticeable.
- Jank: subjective; operationally "frame missed deadline by > 2x".
- ANR (Android): app did not respond to input for >= 5 s (or a broadcast within 10 s). Google Play measures ANR rate per daily user.
- App hang (iOS): main thread blocked >= 250 ms. Reported via MetricKit as
MXAppResponsivenessMetric.
- INP-style metric (mobile): from input event to visible feedback. No platform-standard name yet; model as
ui.interaction.duration_ms histogram.
2. Android -- JankStats
JankStats is the first-party AndroidX API for frame timing. It uses FrameMetrics on API 24+ and gracefully degrades below.
class UxMetricsReporter(activity: Activity, private val otel: OpenTelemetry) {
private val histogram = otel.getMeter("ux").histogramBuilder("mobile.frame.duration_ms")
.setUnit("ms").build()
private val frozenCounter = otel.getMeter("ux").counterBuilder("mobile.frame.frozen.count").build()
private val jank = JankStats.createAndTrack(activity.window) { frame ->
val ms = frame.frameDurationUiNanos / 1_000_000.0
val attrs = Attributes.of(
AttributeKey.stringKey("screen"), activity::class.java.simpleName,
AttributeKey.booleanKey("is_jank"), frame.isJank
)
histogram.record(ms, attrs)
if (ms > 700) frozenCounter.add(1, attrs)
}
fun onResume() { jank.isTrackingEnabled = true }
fun onPause() { jank.isTrackingEnabled = false }
}
3. iOS -- MetricKit + CADisplayLink
Use MetricKit for daily rollups and a CADisplayLink sampler for granular jank signals.
final class FrameSampler {
private var link: CADisplayLink?
private var lastTs: CFTimeInterval = 0
private let histogram = meter.createDoubleHistogram(name: "mobile.frame.duration_ms", unit: "ms")
func start() {
let link = CADisplayLink(target: self, selector: #selector(tick))
link.add(to: .main, forMode: .common)
self.link = link
}
@objc private func tick(_ link: CADisplayLink) {
if lastTs != 0 {
let delta = (link.timestamp - lastTs) * 1000
histogram.record(value: delta, labels: ["screen": .string(currentScreenName())])
}
lastTs = link.timestamp
}
func stop() { link?.invalidate(); link = nil; lastTs = 0 }
}
For hangs, rely on MetricKit.MXAppResponsivenessMetric + the Sentry AppHangTracking or Firebase's hang signal. Custom watchdog threads that kill the process are not recommended for production.
4. Flutter -- SchedulerBinding and DevTools
class FrameMetrics {
static void enable() {
SchedulerBinding.instance.addTimingsCallback((timings) {
for (final t in timings) {
final buildMs = t.buildDuration.inMicroseconds / 1000.0;
final rasterMs = t.rasterDuration.inMicroseconds / 1000.0;
Sentry.metrics().distribution('mobile.frame.build.ms', value: buildMs);
Sentry.metrics().distribution('mobile.frame.raster.ms', value: rasterMs);
if (buildMs + rasterMs > 700) {
Sentry.metrics().increment('mobile.frame.frozen.count');
}
}
});
}
}
Prefer Impeller (default on iOS, rolling to Android) for consistent frame times.
5. React Native -- Perf Monitor + Sentry
- Enable Sentry's
ReactNavigationInstrumentation so screen transitions emit frame metrics.
- Use the
react-native-performance package or PerformanceObserver for longtask-style signals.
- Track the "slow scroll" signal by instrumenting
FlatList onScroll deltas against requestAnimationFrame intervals.
6. Interaction-to-Next-Paint (INP-like)
Instrument the user intent explicitly: from the onPressIn / touchesBegan / onPointerDown to the first re-render where the user sees feedback.
val span = tracer.spanBuilder("ui.interaction.checkout_button").startSpan()
button.setOnClickListener {
span.addEvent("pressed")
onCheckoutClicked { span.addEvent("first_feedback_rendered"); span.end() }
}
Aim for p75 <= 100 ms, p95 <= 250 ms on mid-tier devices.
7. ANR Signals and Mitigation
- Capture ANR counts via Crashlytics/Sentry. Correlate with release and device class.
- Correlate ANRs with the
last_screen custom key and last_operation metric to pin down what blocked the main thread.
- Usual culprits: large image decoding on main thread, synchronous disk I/O, uncached JSON parsing of multi-MB payloads, heavy string formatting in
Log.
- Mitigations:
CoroutineDispatcher.IO / Dispatcher.main.async / Isolates / InteractionManager.runAfterInteractions.
8. Dashboards
- "UX Health" dashboard per platform: p50/p75/p95 frame time by screen, frozen frame rate, ANR rate, hang rate, interaction duration by critical interaction.
- A Top jank screens table ranks screens by (slow frames * sessions on screen).
- A Regression lens overlays the current release vs last two releases.
9. Targets
- Slow frame rate: < 5% of frames.
- Frozen frame rate: < 0.1%.
- ANR rate: < 0.47% of DAU (Google Play cutoff), target < 0.1%.
- App hang rate: < 0.5%.
- p95 critical interaction: < 250 ms on mid-tier.
10. Release Gating
- Block rollout when the UX dashboard shows a regression > 15% in any of the above metrics vs the previous release at the same rollout stage.
- Feature-flag expensive UI changes so you can turn them off remotely if UX regresses.
Checklist