Skip to main content

Hang Detection

Hang Detection​

The Embrace SDK can monitor your application's main thread for app hangs, providing visibility into UI freezes and unresponsive behavior that can frustrate users. Hang detection is opt-in — see Enabling Hang Detection.

What are Hangs?​

A hang occurs when your app's main thread (UI thread) is blocked for too long, preventing the app from responding to user interactions. During a hang, your app may appear frozen—buttons don't respond, animations stop, and the UI becomes unresponsive.

Common causes of hangs include:

  • Performing heavy computations on the main thread
  • Synchronous network requests
  • Large file I/O operations
  • Inefficient database queries
  • Deadlocks or race conditions
  • Slow third-party SDK initialization

Even hangs as short as 250 milliseconds can be noticeable to users and negatively impact the user experience.

For more information about understanding and improving hangs in iOS apps, see Apple's documentation:

How Hang Detection Works​

The SDK monitors the main thread's render loop using a frame-rate monitor (backed by CADisplayLink). Each frame's actual delivery time is compared against the system's own committed schedule for that frame. When the delay exceeds 249 milliseconds (Apple's recommended threshold), the main thread is considered blocked, and a hang is detected and reported with:

  • Start time and duration of the hang
  • A stack trace of the main thread to identify the blocking code
  • Associated session and user context

Comparing against the system's committed schedule rather than a fixed frame duration makes this approach safe under ProMotion, Low Power Mode, and preferredFrameRateRange transitions — none of them produce false positives. CADisplayLink also pauses in the background, so app suspension gaps are never reported as hangs.

Hangs are detected retroactively

The SDK becomes aware of a hang only once the delayed frame is finally delivered — that is, after the main thread has already unblocked. Two consequences:

  • A hang that never recovers (the OS terminates the app while the main thread is still blocked) produces no emb-thread-blockage span. Those appear as watchdog terminations instead, captured via MetricKit.
  • The span is only created after recovery, but its stack trace is not taken then. A background sampler snapshots the main thread while it is still blocked (by default once it has been busy for 150 ms), and the earliest snapshot inside the hang window is attached to the span.

Key Benefits​

  • Detect UI freezes and unresponsive behavior
  • Identify code causing main thread blockages
  • Track hang frequency and duration

Enabling Hang Detection​

Hang detection is opt-in. It is not installed by EmbraceIO.CaptureServicesOptions.default() and is not added by CaptureServicesOptionsBuilder.addDefaults(). You must register the service explicitly when you set up the SDK.

import EmbraceIO

let captureServices = CaptureServicesOptionsBuilder()
.addDefaults()
.addHangCaptureService()
.build()

let options = EmbraceIO.Options.withAppId(
"YOUR_APP_ID",
captureServices: captureServices
)

do {
try EmbraceIO.start(options: options)
} catch {
print("Failed to set up Embrace: \(error)")
}

Tuning HangLimits​

Limits come from your configuration, not from the service

The SDK overwrites the hang service's limits with the active configuration's hangLimits during setup, and again on every configuration refresh.

When you initialize with an appId — EmbraceIO.Options.withAppId — hang limits therefore always come from Embrace's remote configuration, and a locally constructed HangLimits has no effect. Contact Embrace support if you need different thresholds or a higher per-session cap for your app.

A local HangLimits takes effect only in the export-only setup, where you supply your own EmbraceConfigurable via withLocalConfiguration. The simplest approach is to mutate DefaultConfig:

import EmbraceIO
import EmbraceConfiguration

let config = DefaultConfig()
config.hangLimits = HangLimits(
hangThreshold: 0.249, // Min frame delay (seconds) to report as a hang
hangPerSession: 20 // Max hangs per session; 0 disables hang detection
)

let options = EmbraceIO.Options.withLocalConfiguration(
config,
captureServices: CaptureServicesOptionsBuilder()
.addDefaults()
.addHangCaptureService()
.build(),
otel: EmbraceIO.OTelOptions(
spanExporters: [mySpanExporter],
logExporters: [myLogExporter]
)
)

do {
try EmbraceIO.start(options: options)
} catch {
print("Failed to set up Embrace: \(error)")
}

Configuration Options​

HangLimits​
  • hangThreshold (default: 0.249): Minimum duration, in seconds, a frame delay must exceed to be reported as a hang. The default (249 ms) matches Apple's definition of a hang.

  • hangPerSession (default: 20): Maximum hangs to capture per session part. Set to 0 to disable hang detection.

  • sampleTriggerThreshold (default: 0.15): How long, in seconds, the main thread must be continuously busy before the sampler snapshots its stack. It is capped at 60% of hangThreshold so the snapshot lands inside the reported hang, and clamped to between 0.05 and 3600 seconds.

  • samplePollInterval (default: 0.05): How often, in seconds, the background sampler checks whether the main thread is responsive. Clamped to between 0.01 and 60 seconds.

info

When attached to a debugger, hang detection is off. If you wish to enable it, add the environment variable EMBAllowWatchdogInDebugger=1 to your scheme's Run action (Edit Scheme → Run → Arguments).

Hang Detection Threshold​

By default the SDK uses a threshold of 249 milliseconds, matching Apple's definition of a hang. This captures hangs that are noticeable to users. It is adjustable through HangLimits.hangThreshold, subject to the configuration caveat above — in an appId setup the effective value comes from remote configuration.

Data Captured​

For each hang, the SDK captures:

  • Start time and duration
  • One stack trace of the main thread, sampled while the hang was in progress and attached as a span event. If no sample landed inside the hang window, the span is sent without a stack trace.
  • Session context
dSYM Upload Required

Upload dSYM files to see symbolicated stack traces. See dSYM Upload Guide.

How Hangs Appear in OpenTelemetry​

Each hang is reported as an OpenTelemetry span named emb-thread-blockage, with emb.type set to perf.thread_blockage. The span's start and end times bound the hang, so its duration is the measured frame delay.

The main-thread stack trace is attached as a span event named thread_blockage_sample, carrying the encoded stack along with frame_count and sample_overhead attributes.

Best Practices​

Default Settings​

The defaults (hangThreshold: 0.249, hangPerSession: 20) are tuned for production and add minimal overhead: one CADisplayLink callback per frame, a background thread that checks main-thread liveness every 50 ms, and a stack capture only when the main thread has been blocked for 150 ms.

Reducing Hangs​

  • Move heavy work off the main thread
  • Use async/await or GCD for long-running operations
  • Optimize rendering and view hierarchy complexity
  • Profile with Instruments to identify bottlenecks

Disabling Hang Detection​

Hang detection is off by default. To turn it off after enabling it, remove the addHangCaptureService() call from your setup.

It can also be disabled remotely by setting hangPerSession to 0, which prevents the frame-rate monitor from being created. In an appId setup that value is controlled by Embrace's remote configuration, so contact Embrace support if you need the feature switched off without shipping a new build.

Troubleshooting​

Not Seeing Hang Data​

Work through these in order — the first two account for most cases:

  1. Detach the debugger. Hang detection disables itself whenever a debugger is attached, so running from Xcode produces no hangs. Either test on a device launched outside Xcode, or set the EMBAllowWatchdogInDebugger environment variable to 1. The SDK logs a warning when it disables itself for this reason: [FrameRateMonitor] Disabled because a debugger is attached.
  2. Confirm the service was registered. HangCaptureService is not part of addDefaults() or EmbraceIO.CaptureServicesOptions.default(). Verify your setup calls addHangCaptureService().
  3. Verify hangPerSession > 0. With an appId, this value comes from remote configuration. If it is 0, the monitor is never created and the SDK captures nothing without reporting an error — contact Embrace support to confirm the value for your app.
  4. Check the per-session cap. After hangPerSession hangs, later hangs in the same session are dropped, and the SDK logs Dropping hang due to surpassing limit.
  5. Reproduce deliberately with Thread.sleep(forTimeInterval: 0.5) on the main thread. Note that the hang must recover for a span to be produced.
  6. Confirm sessions are uploading successfully.