How can an app detect its own main-thread hangs, and what are the limits of doing it in-process?
Predict first
A main-queue block sleeps for 500 ms. What does the heartbeat watchdog see?
import Foundation
final class Watchdog: @unchecked Sendable {
private let lock = NSLock()
private var lastPong = DispatchTime.now()
private var worst = 0.0
func start() {
Thread { [self] in
while true {
DispatchQueue.main.async { [self] in lock.lock(); lastPong = .now(); lock.unlock() }
Thread.sleep(forTimeInterval: 0.05)
lock.lock()
let stalled = Double(DispatchTime.now().uptimeNanoseconds - lastPong.uptimeNanoseconds) / 1e6
if stalled > worst { worst = stalled }
lock.unlock()
}
}.start()
}
var worstStallMs: Double { lock.lock(); defer { lock.unlock() }; return worst }
}
let w = Watchdog()
w.start()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { Thread.sleep(forTimeInterval: 0.5) }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
print("watchdog saw a stall of at least 400 ms:", w.worstStallMs > 400, "- and under 700 ms:", w.worstStallMs < 700)
exit(0)
}
RunLoop.main.run()
- a worst stall between 400 and 700 ms
- No stall, because the watchdog runs on another thread
- A stall of exactly 50 ms, the polling interval
Mental model. A night guard who phones the front desk every minute. If the desk stops answering, he knows something is wrong, but not which room.
A watchdog is a background thread that repeatedly asks the main queue to record a heartbeat (DispatchQueue.main.async { lastPong = now }), sleeps briefly, and then checks how old the last heartbeat is. If the main thread is busy, the heartbeat does not run and the age grows. In the check the main queue blocked for 500 ms (Thread.sleep inside a main-queue block), and the watchdog’s worst observed stall was between 400 and 700 ms. Production tools use the same idea with a run-loop observer, a threshold of about 250 ms (the system’s own definition of a hang starts there, Q287), and then capture the main thread’s call stack or report the duration to your analytics.
Limits are important. The measurement error is about the polling interval, so a 50 ms poll cannot distinguish a 260 ms from a 300 ms stall. The watchdog tells you that and how long, not why: capturing the main thread’s stack from another thread is possible with private-ish techniques and is fragile, so most teams rely on MetricKit hang diagnostics (Q431) and Instruments for the call stack, and use the watchdog as a trip-wire in debug builds, UI tests or internal builds. Do not ship a watchdog that wakes every few milliseconds, because it costs the energy it is meant to protect (Q437). Not asserted: behaviour of the iOS system watchdog (0x8badf00d), which kills an app that blocks for too long during launch or transitions, and was not reproduced here.
The heartbeat cannot run while the main queue is blocked, so its age grows to about the block length.
Common traps
- Polling too fast. The detector becomes a battery cost.
- Expecting the watchdog to find the cause. It finds the stall; you still need a profile or a stack.
They might follow up with
- Hang or hitch? Roughly: a hang is a long block, a hitch is a late frame (Q287).
- Where else do hang reports come from? Xcode Organizer and MetricKit diagnostic payloads.
Related questions
- 21.10.2 Is moving work off the main actor free, and what does it cost to hop back to it many times?
- 21.1.1 How do you approach a performance problem, and which kinds of problem are there?
- 9.7.1 What is a hitch versus a hang, how does ProMotion change the budget, and how do you measure and fix them?
- 21.3.1 What does MetricKit give you in production, how do you subscribe, and what is the difference between metric…
- 21.6.1 Where does an app’s energy go, and how do timers, networking, location and animation change the battery bill?
Is moving work off the main actor free, and what does it cost to hop… →