[iOS] How to Choose and Safely Use Timer and DispatchSourceTimer?
Encapsulating DispatchSourceTimer with Finite-State Machine and Design Patterns for Safer and Easier Use.

Photo by Ralph Hutter
About Timer
In iOS development, the “Timer trigger” scenario is inevitable; from UI-level countdown displays and banner carousels to data logic-level timed event sending and periodic data cleanup, we all need Timers to help achieve these goals.
Foundation — Timer (NSTimer)
Timer is probably the most intuitive API that comes to mind first, but there are several points to consider when choosing and using Timer.
Advantages and Disadvantages
Advantages of Timer:
-
Default integration with UI tasks, no need to explicitly switch to Main Thread execution
-
Automatically adjusts trigger timing to optimize power usage
-
Lower complexity in usage; may cause retain cycles or forgetting to stop the Timer, but will not directly cause a crash
Disadvantages of Timer:
-
Accuracy is affected by the RunLoop state and may be delayed during high UI interaction or mode switching.
-
Does not support advanced operations like
suspend,resume,activate, etc.
Suitable Scenarios
For UI-level needs, such as carousel banners (auto-scrolling ScrollView) or countdown timers for coupon claims, where the user only needs to interact with the current foreground screen, I choose to use Timer directly. It is convenient, fast, and safe to achieve the goal.
Lifecycle

Creating a Timer on the UI Main Thread means the Timer is strongly held by the Main Thread’s RunLoop and is periodically triggered by the RunLoop’s polling mechanism. It will only be released after calling Timer’s invalidate(). Therefore, we need to strongly hold the Timer in the ViewController and call Timer invalidate() in deinit to properly stop and release the Timer when the view is dismissed.
-
⭐️️️ View Controller strongly holds Timer, the Timer’s execution block (handler/closure) must use weak self; otherwise, it will cause a retain cycle.
-
⭐️️️ Be sure to call Timer invalidate() when the View Controller lifecycle ends, otherwise the RunLoop will still hold the Timer and keep running it.
RunLoop is an event processing loop within a Thread that polls and handles events; the system automatically creates a RunLoop for the Main Thread (RunLoop.main), but other Threads may not have a RunLoop.
Usage
We can directly use Timer.scheduledTimer to declare a Timer (it will automatically add to RunLoop.main & Mode: .default):
final class HomeViewController: UIViewController {
private var timer: Timer?
deinit {
self.timer?.invalidate()
self.timer = nil
}
override func viewDidLoad() {
super.viewDidLoad()
startCarousel()
}
private func startCarousel() {
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in
self?.doSomething()
})
}
private func doSomething() {
print("Hello World!")
}
}
You can also declare a Timer object yourself and add it to the RunLoop:
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
// do something..
}
self.timer = timer
// Adding to RunLoop will start the timer
RunLoop.main.add(timer, forMode: .default)
How to Use Timer
-
invalidate()stops the Timer -
fire()triggers immediately once
The Impact of RunLoop Mode
-
.default: The default added Mode, mainly handles UI display.
Will pause first when switching to.trackingMode -
.tracking: Handles ScrollView scrolling and Gesture recognition. -
.common: Handles both.defaultand.tracking.
⭐️️️⭐️️️⭐️️️Therefore, by default, our Timer is added to the
.defaultmode, which will automatically pause when the user scrolls a ScrollView or performs gesture actions, and will only resume after the operation ends. This may cause the Timer to trigger late or fire fewer times than expected.
For this, we can add the Timer to the .common Mode to solve the above issue:
RunLoop.main.add(timer, forMode: .common)
Grand Central Dispatch — DispatchSourceTimer
Besides Timer, GCD also offers another option called DispatchSourceTimer.
Advantages and Disadvantages
Advantages of DispatchSourceTimer:
-
Better operational flexibility (supports
suspendandresume) -
Higher accuracy and reliability: relies on GCD Queue
-
Leeway can be set to control power consumption
-
Stable resident task (GCD Queue)
Disadvantages of DispatchSourceTimer:
-
UI operations require manually switching back to the Main Thread
-
The API is complex and order-dependent; using it incorrectly will cause a crash
-
Encapsulation is needed for safe usage
Suitable Scenarios
Compared to Timer, which is suitable for UI-related scenarios, DispatchSourceTimer is better for tasks unrelated to the UI or the user’s current screen. The most common use cases include sending tracking events regularly, where user actions are sent to the server periodically, or cleaning up unused CoreData data at set intervals. These tasks are well suited for DispatchSourceTimer.
Lifecycle

The lifecycle of a DispatchSourceTimer depends on whether it is still retained by an external object; the GCD queue itself does not strongly retain the timer’s owner and only handles scheduling and executing events.
Crash Issues
Although DispatchSourceTimer offers more control methods: activate, suspend, resume, cancel; it is extremely sensitive. Calling them in the wrong order can cause immediate crashes (EXC_BREAKPOINT/DispatchSourceTimer), which is very dangerous.

The app will crash immediately in the following cases:
-
❌ Calling suspend() and resume() not in pairs
Calling suspend() twice in a row
Calling resume() twice in a row -
❌ Calling cancel() after suspend()
You need to call resume() before cancel() -
❌ Timer Released (nil) While in suspend() State
-
❌ Calling other operations after cancel()
Using Finite-State Machine to Encapsulate Operations
Moving on to another key point of this article: How to safely use DispatchSourceTimer?

As shown in the above figure, we use a finite-state machine to encapsulate DispatchSourceTimer operations, making it safer and easier to use:
final class DispatchSourceTimerMachine {
// States of the finite state machine
private enum TimerState {
// Initial state
case idle
// Running
case running
// Suspended
case suspended
// Cancelled
case cancelled
}
private var timer: DispatchSourceTimer?
private lazy var timerQueue: DispatchQueue = {
DispatchQueue(label: "li.zhgchg.DispatchSourceTimerMachine", qos: .background)
}()
private var _state: TimerState = .idle
deinit {
// When the owner object deallocates, synchronously cancel the timer
// Not mandatory since the handler is weak, but ensures expected flow
if _state == .suspended {
timer?.resume()
_state = .running
}
if _state == .running {
timer?.cancel()
timer = nil
_state = .cancelled
}
}
// Start the Timer
func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
// Timer can only be activated from idle or cancelled states
guard [.idle, .cancelled].contains(_state) else { return }
// Create Timer and activate()
let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
self.timer = timer
timer.activate()
// Switch to running state
_state = .running
}
// Suspend the Timer
func suspend() {
// Timer can only be suspended when running
guard [.running].contains(_state) else { return }
// Suspend Timer
timer?.suspend()
// Switch to suspended state
_state = .suspended
}
// Resume the Timer
func resume() {
// Timer can only be resumed when suspended
guard [.suspended].contains(_state) else { return }
// Resume Timer
timer?.resume()
// Switch to running state
_state = .running
}
// Cancel the Timer
func cancel() {
// Timer can only be cancelled when suspended or running
guard [.suspended, .running].contains(_state) else { return }
// If currently suspended, resume before cancelling
// This is a DispatchSourceTimer limitation; cancel only allowed when running
if _state == .suspended {
self.resume()
}
// Cancel Timer
timer?.cancel()
timer = nil
// Switch to cancelled state
_state = .cancelled
}
private func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> DispatchSourceTimer {
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.schedule(deadline: .now(), repeating: repeatTimeInterval)
timer.setEventHandler(qos: .background, handler: handler)
return timer
}
}
We simply used a finite-state machine to encapsulate the logic of “which states can transition to which” and “what each state needs to do.” Calls made in the wrong state are ignored (no crashes). We also added some optimizations, such as allowing cancel in the suspended state and re-activating from the cancelled state.
Further Reading:
Previously, I wrote another article “Design Patterns Practical Application|Encapsulating Socket.IO Real-Time Communication Architecture,” which also uses a finite-state machine and applies the State Pattern.
Finite-State Machine: Focuses on controlling transitions between states and the actions to perform.
State Pattern: Focuses on the behavior logic within each state.
Using Serial Queue to Handle Finite-State Machine State Transitions
Ensuring safe use of DispatchSourceTimer with a state machine is not the end. We cannot guarantee that calls to DispatchSourceTimerMachine from outside occur on the same thread. If different threads operate on this object, it can cause race conditions and lead to crashes.
final class DispatchSourceTimerMachine {
// States of the finite state machine
private enum TimerState {
// Initial state
case idle
// Running
case running
// Suspended
case suspended
// Cancelled
case cancelled
}
private var timer: DispatchSourceTimer?
private lazy var timerQueue: DispatchQueue = {
DispatchQueue(label: "li.zhgchg.DispatchSourceTimerMachine", qos: .background)
}()
private var _state: TimerState = .idle
private static let operationQueueSpecificKey = DispatchSpecificKey<ObjectIdentifier>()
private lazy var operationQueueSpecificValue: ObjectIdentifier = ObjectIdentifier(self)
private lazy var operationQueue: DispatchQueue = {
let queue = DispatchQueue(label: "li.zhgchg.DispatchSourceTimerMachine.operationQueue")
queue.setSpecific(key: Self.operationQueueSpecificKey, value: operationQueueSpecificValue)
return queue
}()
private func operation(async: Bool = true, _ work: @escaping () -> Void) {
if DispatchQueue.getSpecific(key: Self.operationQueueSpecificKey) == operationQueueSpecificValue {
work()
} else {
if async {
operationQueue.async(execute: work)
} else {
operationQueue.sync(execute: work)
}
}
}
deinit {
// When the owner object is deallocated, synchronously cancel the timer
// Although not necessary (handler is weak), this ensures the flow is as expected
// Ensure sync finishes execution
operation(async: false) { [self] in
if _state == .suspended {
timer?.resume()
_state = .running
}
if _state == .running {
timer?.cancel()
timer = nil
_state = .cancelled
}
}
}
// Start the Timer
func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
operation { [weak self] in
guard let self = self else { return }
// Timer can only be activated in idle or cancelled states
guard [.idle, .cancelled].contains(_state) else { return }
// Create Timer and activate()
let timer = makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
self.timer = timer
timer.activate()
// Switch to running state
_state = .running
}
}
// Suspend the Timer
func suspend() {
operation { [weak self] in
guard let self = self else { return }
// Timer can only be suspended when running
guard [.running].contains(_state) else { return }
// Suspend the Timer
timer?.suspend()
// Switch to suspended state
_state = .suspended
}
}
// Resume the Timer
func resume() {
operation { [weak self] in
guard let self = self else { return }
// Timer can only be resumed when suspended
guard [.suspended].contains(_state) else { return }
// Resume the Timer
timer?.resume()
// Switch to running state
_state = .running
}
}
// Cancel the Timer
func cancel() {
operation { [weak self] in
guard let self = self else { return }
// Timer can only be cancelled when suspended or running
guard [.suspended, .running].contains(_state) else { return }
// If currently suspended, resume first before cancelling
// This is a DispatchSourceTimer limitation; it can only be cancelled when running
if _state == .suspended {
self.resume()
}
// Cancel the Timer
timer?.cancel()
timer = nil
// Switch to cancelled state
_state = .cancelled
}
}
private func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> DispatchSourceTimer {
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.schedule(deadline: .now(), repeating: repeatTimeInterval)
timer.setEventHandler(qos: .background, handler: handler)
return timer
}
}
Now, we can safely use the DispatchSourceTimerMachine object as a Timer without any worries:
final class TrackingEventSender {
private let timerMachine = DispatchSourceTimerMachine()
public var events: [String: String] = []
// Start periodic tracking
func startTracking() {
timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
self?.sendTrackingEvent()
}
}
// Pause tracking (e.g., when App goes to background)
func pauseTracking() {
timerMachine.suspend()
}
// Resume tracking (e.g., when App returns to foreground)
func resumeTracking() {
timerMachine.resume()
}
// Stop tracking (e.g., when leaving the page)
func stopTracking() {
timerMachine.cancel()
}
private func sendTrackingEvent() {
// send events to server...
}
}
The section on how to safely use DispatchSourceTimer has concluded. Next, we will extend to several Design Patterns to help us abstract objects for testing and abstract the execution logic of DispatchSourceHandler.
Extension — Using Adapter Pattern + Factory Pattern to Create DispatchSourceTimer (Facilitates Abstract Testing)
DispatchSourceTimer is a GCD Objective-C object, making it difficult to mock during testing (no Protocol available); therefore, we need to define a Protocol + Factory Pattern layer ourselves so that TimerStateMachine can be testable.
Adapter Pattern— Encapsulating DispatchSourceTimer Operations:
public protocol TimerAdapter {
func schedule(repeating: DispatchTimeInterval)
func setEventHandler(handler: DispatchSourceProtocol.DispatchSourceHandler?)
func activate()
func suspend()
func resume()
func cancel()
}
// Adapter implementation for DispatchSourceTimer
final class DispatchSourceTimerAdapter: TimerAdapter {
// The original DispatchSourceTimer
private let timer: DispatchSourceTimer
init(label: String = "li.zhgchg.DispatchSourceTimerAdapter") {
let queue = DispatchQueue(label: label, qos: .background)
let timer = DispatchSource.makeTimerSource(queue: queue)
self.timer = timer
}
func schedule(repeating: DispatchTimeInterval) {
timer.schedule(deadline: .now(), repeating: repeating)
}
func setEventHandler(handler: DispatchSourceProtocol.DispatchSourceHandler?) {
timer.setEventHandler(qos: .background, handler: handler)
}
func activate() {
timer.activate()
}
func suspend() {
timer.suspend()
}
func resume() {
timer.resume()
}
func cancel() {
timer.cancel()
}
}
Factory Pattern — Abstracting the creation method of TimerAdapter:
protocol DispatchSourceTimerAdapterFactorySpec {
func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> TimerAdapter
}
// Encapsulates the creation steps of DispatchSourceTimerAdapter
final class DispatchSourceTimerAdapterFactory: DispatchSourceTimerAdapterFactorySpec {
public func makeTimer(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSourceProtocol.DispatchSourceHandler?) -> TimerAdapter {
let timer = DispatchSourceTimerAdapter()
timer.schedule(repeating: repeatTimeInterval)
timer.setEventHandler(handler: handler)
return timer
}
}
Combined Usage:
var stateMachine = DispatchSourceTimerMachine(timerFactory: DispatchSourceTimerAdapterFactory())
//
final class DispatchSourceTimerMachine {
// Omitted..
private var timer: TimerAdapter?
private let timerFactory: DispatchSourceTimerAdapterFactorySpec
public init(timerFactory: DispatchSourceTimerAdapterFactorySpec) {
self.timerFactory = timerFactory
}
// Omitted..
func activate(repeatTimeInterval: DispatchTimeInterval, handler: DispatchSource.DispatchSourceHandler?) {
onQueue { [weak self] in
guard let self else { return }
guard [.idle, .cancelled].contains(_state) else { return }
// Use Factory to make timer
let timer = timerFactory.makeTimer(repeatTimeInterval: repeatTimeInterval, handler: handler)
self.timer = timer
timer.activate()
_state = .running
}
}
// Omitted..
}
This allows us to write Mock Objects for TimerAdapter / DispatchSourceTimerAdapterFactorySpec during testing to run unit tests.
Extension — Using the Strategy Pattern to Encapsulate DispatchSourceHandler Tasks
Assuming we want the DispatchSourceHandler to execute tasks that can change dynamically, we can use the Strategy Pattern to encapsulate the work content.
TrackingHandlerStrategy:
protocol TrackingHandlerStrategy {
static var target: String { get }
func execute()
}
// Home Event
final class HomeTrackingHandlerStrategy: TrackingHandlerStrategy {
static var target: String = "home"
func execute() {
// fetch home event logs..and send
}
}
// Product Event
final class ProductTrackingHandlerStrategy: TrackingHandlerStrategy {
static var target: String = "product"
func execute() {
// fetch product event logs..and send
}
}
Combined Usage:
var sender = TrackingEventSender()
sender.register(event: HomeTrackingHandlerStrategy())
sender.register(event: ProductTrackingHandlerStrategy())
sender.startTracking()
// ...
//
final class TrackingEventSender {
private let timerMachine = DispatchSourceTimerMachine()
private var events: [String: TrackingHandlerStrategy] = [:]
// Register required Event strategies
func register(event: TrackingHandlerStrategy) {
events[type(of: event).target] = event
}
func retrive<T: TrackingHandlerStrategy>(event: T.Type) -> T? {
return events[event.target] as? T
}
// Start periodic tracking
func startTracking() {
timerMachine.activate(repeatTimeInterval: .seconds(30)) { [weak self] in
self?.events.values.forEach { event in
event.execute()
}
}
}
// Pause tracking (e.g., when App goes to background)
func pauseTracking() {
timerMachine.suspend()
}
// Resume tracking (e.g., when App comes to foreground)
func resumeTracking() {
timerMachine.resume()
}
// Stop tracking (e.g., when leaving the page)
func stopTracking() {
timerMachine.cancel()
}
}
Acknowledgments
Thanks to Ethan Huang for donating 5 Beers:
Indeed, I haven’t written much for almost half a year. Just started a new job and am still searching for inspiration! 💪
The next article might share the process of managing certificates with Fastlane Match and setting up a Self-hosted Runner… or Bitbucket Pipeline… or the AppStoreConnect API…




Comments