DispatchSourceTimer on macOS: Leeway, Coalescing, and Safe Cancellation
Build resilient GCD timer sources with monotonic deadlines, justified leeway, serialized state, balanced suspension, and race-safe teardown.
A DispatchSourceTimer submits an event handler to a dispatch queue when its timer condition is met. It is useful for periodic housekeeping and deadline-driven work that should not depend on a particular run loop. It is not a real-time clock, a durable scheduler, or a guarantee that a handler fires at the exact requested nanosecond. Dispatch timers can be delayed within their leeway to improve power and system efficiency, and event handling can be delayed further by a busy target queue.
The key lifecycle decisions are when to schedule, which clock to use, which queue runs the handler, what happens if a tick arrives while work is still running, and how the source is cancelled and released. Those decisions should be explicit before adding a timer to an object that may outlive a view or document.
The compact owner below assumes start() and stop() are called from one serialized feature context. The property check is not a lock; if callers can race from multiple threads, serialize those state transitions or protect them with synchronization.
Create, schedule, and activate once
Create the source with a queue that matches the handler’s work. A serial queue is appropriate when tick state must not overlap. A main queue is appropriate only for short UI updates; expensive scanning or decoding should run elsewhere. Install the event handler and schedule the timer before activation so the first event cannot arrive before state is ready.
import Dispatch
final class RefreshTimer {
private let queue = DispatchQueue(label: "com.example.refresh-timer")
private var source: (any DispatchSourceTimer)?
func start() {
guard source == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(
deadline: .now() + .seconds(5),
repeating: .seconds(60),
leeway: .seconds(5)
)
timer.setEventHandler { [weak self] in
self?.refreshBoundedState()
}
source = timer
timer.activate()
}
func stop() {
source?.setEventHandler {}
source?.cancel()
source = nil
}
deinit {
stop()
}
private func refreshBoundedState() {
// Keep the tick idempotent and bounded in duration.
}
}
The example uses a relative DispatchTime deadline and a generous leeway for a nonurgent refresh. It does not launch network requests; a production poller should decide what to do when one refresh is still in flight at the next tick. Coalesce redundant refresh requests, skip a tick, or set a single pending flag rather than building an unbounded queue of duplicate operations.
Choose the clock that matches the requirement
DispatchTime is based on a monotonic system clock and is suitable for measuring intervals and scheduling relative deadlines. DispatchWallTime expresses a wall-clock time. A timer for “check again in five minutes” has different semantics from “run at 09:00 local time.” Daylight-saving changes, clock correction, sleep, and system time changes matter differently to those requirements.
Do not implement calendar scheduling by adding a fixed number of seconds to the current wall time. For “at the next local day boundary,” use calendar-aware date computation and re-evaluate if the time zone changes. A GCD timer is an in-process wakeup mechanism; if an event must happen while the app is not running, use a system scheduling facility or a server-side job, not an in-memory dispatch source.
Timers are subject to system scheduling and power management. Even a strict timer makes the system’s best effort to observe its leeway; it is not a hard real-time deadline. Keep deadline-sensitive correctness in the operation itself. When a delayed tick arrives, calculate elapsed time from a monotonic clock rather than assuming exactly one interval passed.
Leeway and event coalescing
Leeway is the maximum amount of time the system may delay a timer event after the requested deadline, according to the API’s documented scheduling semantics. For repeated timers, the first firing’s allowed delay is the leeway; subsequent deliveries are based on the deadline and interval with a maximum delay bounded by the smaller of leeway and half the interval. The system can also deliver events sooner than the deadline in documented cases, so do not treat the callback time as a precise timestamp for the intended deadline.
Give periodic maintenance enough leeway to align with other system activity. A leeway of zero does not make a timer perfectly punctual and can increase wakeups. Tightening leeway should be a measured product requirement, not a reflex to solve perceived sluggishness. Store the intended deadline and actual callback time in diagnostics so you can distinguish scheduler delay from slow handler execution.
Dispatch sources coalesce pending events rather than guaranteeing one callback for every elapsed interval. If the queue is busy, several timer firings can represent one pending timer event. Design a periodic task to reconcile current state at each callback. Do not use callback count as a financial ledger or count of exact missed intervals.
Handler duration and overlap policy
The timer event handler runs on the queue supplied during source creation. If that queue is serial and the handler takes longer than the repeat interval, future handler execution waits; the source does not make the work parallel by itself. If the queue is concurrent, your state may race unless you synchronize it. Choose a queue based on state ownership and then keep the tick body bounded.
For asynchronous work, the handler should generally schedule work and return instead of blocking the dispatch queue. Keep an in-flight task token so a timer cannot launch an unlimited number of overlapping refreshes. If a tick occurs while work is running, define whether to skip, coalesce, or record a single pending rerun. Ensure cancellation of the timer also cancels the work it owns when that matches product semantics.
Avoid retaining a view controller strongly from a repeating timer. The source can keep a target queue and event handler active for a long time. Use a dedicated owner, weak capture where appropriate, and a teardown path connected to the feature lifecycle. Weak capture prevents a retain cycle but does not guarantee the timer is cancelled when the owner disappears; clear source ownership explicitly.
Cancellation and suspension invariants
Calling cancel() asynchronously prevents further delivery of new events, but an event handler already running can finish. Make cleanup safe if it races with a tick. If a cancellation handler owns a descriptor or other resource, release it there after the source has stopped using it. For a timer without external resources, a cancellation handler is optional, but it can still centralize final bookkeeping.
Suspension is a separate state from cancellation. Every suspend() must be balanced by a resume() before the final reference is released; releasing a dispatch object while suspended has undefined behavior. Avoid adding suspension unless the component has a state machine that tracks the suspension count or a single owner that guarantees exactly one resume. In many cases, cancel and recreate a timer with a new schedule rather than pause it ambiguously.
Cancellation cannot be undone. After a source is cancelled, scheduling it again has no effect; create a fresh source for a new lifecycle. Do not expose the source to multiple owners that can independently cancel, suspend, or resume it. Wrap these operations behind a timer coordinator that serializes state transitions.
Background, sleep, and app lifecycle
A dispatch timer exists only while the process and source exist. It does not grant background execution time or guarantee a callback while the app is suspended. When the computer sleeps or the app loses execution opportunity, periodic work may be delayed or resume under timing semantics that differ from a simple wall-clock schedule. Reconcile state on app activation and use a platform background mechanism for work that must run under system-managed conditions.
If the timer performs a server poll, use an operation ID and bounded retry. When the app resumes after a long delay, make one state reconciliation request rather than replaying every missed timer tick. If local state changes while the timer is inactive, the foreground path should still refresh it.
Testing and diagnostics
Test start twice, stop before first firing, stop from inside the event handler, restart after cancellation, handler longer than interval, queue saturation, app suspend and resume, system sleep, wall-clock change, and object teardown while a callback is pending. Assert no overlapping operation when prohibited, one cleanup, no callback mutates a deallocated owner, and no source remains active after its feature ends.
Record scheduled deadline, callback time, handler duration, queue depth or in-flight status, cancellation reason, and whether a tick was skipped or coalesced. Do not log every tick at high frequency in production. A timer that fires on time but then waits ten seconds on a serial queue is a queue backlog problem, not a leeway problem.
Use DispatchSourceTimer for in-process event timing that tolerates scheduler variation. Choose a suitable clock and leeway, make work bounded, and own suspension and cancellation in one state machine. For exact calendar events, hard real-time behavior, or jobs that survive process exit, use a different mechanism.
Related:
- Grand Central Dispatch on macOS: Queues, QoS, Barriers, and Deadlocks
- macOS Run Loops: Sources, Modes, Timers, and Thread Ownership
Sources: