Setting Up App Hang / UI Freeze Monitoring for Mobile Apps
An app hang is not a crash. The app is alive, but doesn't respond to touches. Users see a frozen screen, tap the button again, then again — and leave. Crashlytics shows nothing, the user stays silent, and conversion drops.
iOS Watchdog terminates the process on hang > 4–8 seconds. Android generates ANR after > 5 seconds. But 200–500ms hangs don't trigger system events — they just kill UX.
Sources of Hangs
On iOS, the most common source of short freezes is a synchronous main thread call triggered by a UI event:
// Antipattern: decode large JSON on main thread
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ProductCell", for: indexPath) as! ProductCell
// If product.image is Data that needs decoding, this blocks main thread
cell.imageView?.image = UIImage(data: product.imageData)
return cell
}
UIImage(data:) synchronously decodes JPEG/PNG. On iPhone SE with 1900x1200 image — 30–80ms main thread block per cellForRowAt.
On Android, the main culprit in Compose screens is unnecessary recomposition in LazyColumn:
// Antipattern: unstable type in LazyColumn
@Composable
fun ProductList(products: List<Product>) { // List<> is not stable
LazyColumn {
items(products) { product ->
ProductCard(product) // redraws on any parent change
}
}
}
Replacing List<Product> with ImmutableList<Product> (kotlinx.collections.immutable) or using @Stable annotation on data class eliminates extra recompositions.
Detection Tools
iOS — MetricKit Hang Diagnostics
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
payload.hangDiagnostics?.forEach { hang in
let duration = hang.hangDuration
let callStack = hang.callStackTree
// hang.hangDuration — MXAverage, not exact
// Minimal MetricKit tracking: hang > 250ms
print("Hang duration: \(duration.averageMeasurement)")
}
}
}
MetricKit delivers data with daily delay. For real-time, need a custom solution.
iOS — Sentry App Hang Detection
SentrySDK.start { options in
options.dsn = "https://[email protected]/project"
options.enableAppHangTracking = true
options.appHangTimeoutInterval = 0.25 // 250ms — report threshold
}
Sentry runs a watchdog thread that pings the main thread every 100ms. If no response > appHangTimeoutInterval, it captures the stack via backtrace_thread and sends it as an Issue.
Android — Jetpack Janky Frames
// FrameMetricsAggregator from AndroidX
val frameMetrics = FrameMetricsAggregator(FrameMetricsAggregator.JANK_DATA)
frameMetrics.add(activity)
// Later:
val metrics = frameMetrics.metrics
metrics?.get(FrameMetricsAggregator.JANK_INDEX)?.let { jankArray ->
val jankyFrames = jankArray.size
// Count of frames > 16ms
}
Android — Android Profiler and Perfetto
These tools aren't used in production, but for local diagnostics they're invaluable. Android Profiler shows System Trace: where main thread is busy, which locks are waited, where GC pauses occur.
Perfetto with android.view.Choreographer track shows dropped frames by screen.
Monitoring Setup in Datadog
// Long Tasks tracking in Datadog RUM
RUM.enable(with: RUM.Configuration(
applicationID: "your-rum-app-id",
longTaskThreshold: 0.1 // 100ms — anything longer is Long Task
))
In Datadog dashboard, build a widget:
count:rum.long_task{env:production,service:ios-app}
group_by: @view.name
visualize_as: top_list
This shows which screens have the most Long Tasks — and that's where you dig deeper.
What We Do
- Connect Sentry
enableAppHangTrackingwith 250ms threshold (iOS) - Configure MetricKit subscriber for daily diagnostics
- Enable Datadog RUM Long Task tracking with 100ms threshold
- On Android, set up
FrameMetricsAggregatorfor key Activities - Build dashboard by screens with most Long Tasks
- Analyze stack traces and identify specific culprits
Timeline
Basic setup via Sentry and Datadog: 4–8 hours. Full diagnostics with MetricKit and custom per-screen metrics: 1–2 days. Pricing is calculated individually.







