Swift: Run Code At Specific Time. RunLoop vs DispatchQueue
Disclaimer!
I am not interested in discussing about those two in terms of usage with
receive(on:options:)of Combine but to schedule a task to run at a specific time.
When we think about trying to run a piece of code at some specific time, there are couple options.
RunLoop+TimerDispatchQueueperform(_:with:afterDelay:)that we will not be talking about again in this article because it is only defined on anNSObjectand the usage is pretty restricted.
In this article, let’s check out the first two options.
The basic usage, the biggest difference (invalidation!), and which to choose!
With some example code!
(I also get a little bonus at the end for passing in parameters to selectors!)
Basic Usages
DispatchQueue
Use the asyncAfter(deadline:execute:) method! End
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: { [weak self] in
// ...
})RunLoop + Timer
- Create a
Timerthat will be fired at a specific time - Add it to
RunLoop.main
let date = Date.now.addingTimeInterval(3)
let timer = Timer(fireAt: date, interval: 0, target: self, selector: #selector(...), userInfo: nil, repeats: false)
RunLoop.main.add(timer, forMode: .common)Sample Code
Just for a better image, let’s put together some sample code to try both out!
import SwiftUI
@Observable
private class DataManager {
var colors: [Color] = [.red, .blue, .yellow, .green, .pink, .orange, .mint]
func scheduleRunloopTask() {
print(#function)
let date = Date.now.addingTimeInterval(3)
let timer = Timer(fireAt: date, interval: 0, target: self, selector: #selector(refreshData), userInfo: nil, repeats: false)
RunLoop.main.add(timer, forMode: .common)
}
func scheduleDispatchQueueTask() {
print(#function)
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: { [weak self] in
self?.refreshData()
})
}
@objc
private func refreshData() {
print(#function)
self.colors = self.colors.shuffled()
}
}
struct RunLoopDispatchQueueTaskDemo: View {
@State private var dataManager = DataManager()
var body: some View {
NavigationStack {
List {
ForEach(dataManager.colors, id: \.self) { color in
Text("\(color.description)")
.font(.headline)
.fontWeight(.bold)
.frame(maxWidth: .infinity, alignment: .center)
.foregroundStyle(.white)
.listRowBackground(color)
}
}
.listRowSpacing(16.0)
.environment(\.defaultMinListRowHeight, 56)
.toolbar(content: {
ToolbarItemGroup(placement: .topBarTrailing, content: {
Button(action: {
self.dataManager.scheduleRunloopTask()
}, label: {
Text("RunLoop")
})
Button(action: {
self.dataManager.scheduleDispatchQueueTask()
}, label: {
Text("DispatchQueue")
})
})
})
}
}
}Yes, we have and need a separate class here because the timer needs a selector, ie: objc!
Differences
If you have tried to use the RunLoop or DispatchQueue with receive(on:options:), you might know that the execution of closures scheduled with RunLoop will be delayed after user interactions whereas DispatchQueue will run those immediately.
However, this does not seem to be a consideration in our scenario, both will interrupt the user interaction and update the UI.
It might be hard to realize but both interrupted my pull-down action and updated the list!
⭐⭐⭐ Recursive Scheduling & Invalidation ⭐⭐⭐
First of all, let’s think about the scheduling part.
Get Itsuki’s stories in your inbox
Join Medium for free to get updates from this writer.
If it is periodic, with Timer we can simply just set repeats to true.
If it is not, with either approach, we will have to add some additional logic to our refreshData , for example, to run scheduleRunloopTask again.
func scheduleRunloopTask(_ timeInterval: TimeInterval) {
print(#function)
let date = Date.now.addingTimeInterval(timeInterval)
let timer = Timer(fireAt: date, interval: 0, target: self, selector: #selector(printData(_:)), userInfo: nil, repeats: false)
RunLoop.main.add(timer, forMode: .common)
}
@objc
private func refreshData() {
print(#function)
self.colors = self.colors.shuffled()
self.scheduleRunloopTask(Double.random(in: 0..<5))
}This is not really a problem with either of the approach, what really needs to be considered here is how we can stop this loop!
You might think we can simply set a flag in our DataManager and schedule the function only if the flag is true.
It might work for simple cases, but not in most scenarios.
Let’s slightly modify our DataManager a little bit.
This time, we have a counter and every time it is updated, we will scheduleRunloopTask to printCounter.
@Observable
private class DataManager {
var counter: Int = 0 {
didSet {
self.scheduleRunloopTask(Double.random(in: 0..<5))
}
}
func scheduleRunloopTask(_ timeInterval: TimeInterval) {
print(#function)
let date = Date.now.addingTimeInterval(timeInterval)
let timer = Timer(fireAt: date, interval: 0, target: self, selector: #selector(printData(_:)), userInfo: nil, repeats: false)
RunLoop.main.add(timer, forMode: .common)
}
@objc
private func printCounter() {
print(#function)
print(self.counter)
self.scheduleRunloopTask(Double.random(in: 0..<5))
}
}If we think about it, while we update the counter and schedule a new loop, we are not invalidating the previous timer scheduled (or the previous recursive looping), and we will just get more and more timer running in the background! Trying to print out those counters! All at once!
And of course, using DispatchQueue will cause the exact same problem.
How can we solve it?
Of course, a flag will not work. Because we don’t want to stop ALL the scheduling but only the previous one!
Unfortunately, I cannot find a solution with DispatchQueue (and that’s why I didn’t even include it any more in my code above)!
However, with RunLoop, we can simply keep a reference to the Timer and call invalidate to stop the timer from ever firing again and requests its removal from its run loop.
@Observable
private class DataManager {
var counter: Int = 0 {
didSet {
self.timer?.invalidate()
self.timer = nil
self.scheduleRunloopTask(Double.random(in: 0..<5))
}
}
var timer: Timer? = nil
func scheduleRunloopTask(_ timeInterval: TimeInterval) {
print(#function)
self.timer?.invalidate()
let date = Date.now.addingTimeInterval(timeInterval)
let timer = Timer(fireAt: date, interval: 0, target: self, selector: #selector(printData(_:)), userInfo: nil, repeats: false)
self.timer = timer
RunLoop.main.add(timer, forMode: .common)
}
@objc
private func printData() {
print(#function)
print(self.counter)
self.scheduleRunloopTask(Double.random(in: 0..<5))
}
}This time, even if we have incremented the counter, still one print statement at a time!
Which One To Choose
Now that we have checked out the biggest difference, the question becomes which one to choose?
In the case of using with receive(on:options:) , I know a lot of people recommend using DispatchQueue, but unfortunately, for our scenario here in this article, ie: Running code at a specific time, I have to say the winner is RunLoop + Timer !
Despite of that annoying objective-c!
Being able to invalidate is huge!
With that being said, if you are not recursively scheduling, DispatchQueue should just be enough, or maybe even prefer, especially in terms of being able to use it directly in a View!
A Little Bonus: Pass Parameters to Selector
Unfortunately, those untyped userInfo dictionary is the only way to go!
func scheduleRunloopTask(_ timeInterval: TimeInterval) {
print(#function)
self.timer?.invalidate()
let date = Date.now.addingTimeInterval(timeInterval)
let userInfo: [String: Date] = ["date": date]
let timer = Timer(fireAt: date, interval: 0, target: self, selector: #selector(printData(_:)), userInfo: userInfo, repeats: false)
self.timer = timer
RunLoop.main.add(timer, forMode: .common)
}
@objc
private func printData(_ timer: Timer) {
print(#function)
print(self.counter)
if let userInfo = timer.userInfo as? [String: Any], let date = userInfo["date"] as? Date {
print("date: \(date)")
}
self.scheduleRunloopTask(Double.random(in: 0..<5))
}Thank you for reading!
That’s it for this article!
Happy run-looping!











