What are run loop observers, in which order do they fire, and what do frameworks use them for?

6.10.2 · L5 · Learning

Predict first

What does the observer log show while a timer is pending?

import Foundation
nonisolated(unsafe) var log: [String] = []
let obs = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.allActivities.rawValue, true, 0) { _, a in
    switch a {
    case .beforeTimers: log.append("beforeTimers")
    case .beforeSources: log.append("beforeSources")
    case .beforeWaiting: log.append("beforeWaiting")
    case .afterWaiting: log.append("afterWaiting")
    default: break
    }
}
CFRunLoopAddObserver(CFRunLoopGetMain(), obs, .commonModes)
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: false) { _ in log.append("TIMER") }
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2))
let i = log.firstIndex(of: "TIMER")!
print("while idle the loop sleeps (beforeWaiting, then afterWaiting) before the timer:", log[..<i].contains("beforeWaiting") && log[..<i].contains("afterWaiting"))
print("the timer fires right after waking:", log[i - 1] == "afterWaiting")
print("each pass: beforeTimers then beforeSources:", zip(log, log.dropFirst()).contains { $0 == "beforeTimers" && $1 == "beforeSources" })
print("beforeWaiting is the last observer point before sleeping:", log.firstIndex(of: "beforeWaiting")! < log.firstIndex(of: "afterWaiting")!)
  • The loop goes idle (beforeWaiting, afterWaiting) before the timer, which fires right after waking; each pass is beforeTimers then beforeSources
  • The timer fires before any waiting activity
  • beforeSources comes before beforeTimers

Mental model. Observers are the bell the waiter rings at fixed points: “starting my round”, “checking the kitchen”, “about to sit down”, “back on my feet”. The restaurant uses the “about to sit down” bell to bring out everything that is ready in one trip.

A CFRunLoopObserver is a callback attached to a loop for chosen activities: entry, before timers, before sources, before waiting, after waiting and exit. The check logs them while a 50 ms timer is pending: each pass goes beforeTimers then beforeSources; when there is no work the loop reports beforeWaiting, sleeps in the kernel (no CPU), wakes with afterWaiting, and the timer fires immediately after the wake-up. So beforeWaiting is the last moment the loop runs your code before going idle, and afterWaiting is the first after something woke it.

That placement is why frameworks use observers. Core Animation registers one near before waiting (and the autorelease pool is drained there) to commit pending layer changes once per pass, which is why several property changes in one event become one visual update and why setNeedsLayout takes effect “soon” rather than instantly. In your own code, an observer is a cheap hook for “run this when the main thread becomes idle”, for measuring main-thread stalls (record the time between afterWaiting and the next beforeWaiting; a long gap is a hang, which is how several hang detectors work), and for batching work once per pass. Keep observer callbacks short: they run on the loop’s thread, a slow one delays everything else exactly like a slow event handler does. Register with .commonModes if it must keep running during scrolling (Q217).

Observer activities are fixed points in each pass; sleeping happens between beforeWaiting and afterWaiting.

Common traps

  • Doing heavy work in a beforeWaiting observer. It runs on the main thread and delays the next event as much as any other main-thread work.
  • Assuming a one-to-one link between a loop pass and a frame. The display link and the render server decide frame timing; the commit observer only hands changes over.

They might follow up with

  • How would I detect main-thread hangs? Observe afterWaiting and beforeWaiting and flag a pass longer than a threshold; MetricKit’s hang diagnostics report the same.
  • Is a pass the same as a frame? No; many passes can happen per frame, and an idle app does not loop at all.

Related questions

  • 6.3.1 What is a run loop, and what happens in one iteration?
  • 6.3.2 What are run loop modes, and why does a Timer stop firing while the user scrolls?
  • 6.10.1 Why does a Timer created on a background thread never fire, and when does a thread’s run loop return…
  • 6.10.3 Which work still runs while the run loop is in a tracking or custom mode, and what does “common modes” really…

← Why does a Timer created on a background thread never fire, and when…   Which work still runs while the run loop is in a tracking or custom… →