A Simple Swift Background Timer

Arik Segal
CodeX
Published in
9 min readFeb 4, 2023

Example code

Timers in Swift

Scheduling a peace of code to be executed on some later time in the near future (for example, 3 seconds from now) is rather easy in most programming languages. An iOS developer can use any of the following methods to do that, depending on the specific context and needs:

  1. NSObject performSelector:withObject:afterDelay:
  2. GCD dispatch_after (in Swift it was renamed to: asyncAfter)
  3. The NSTimer class (in Swift it was renamed to: the Timer class)
  4. Combine Framework -> Publisher.Delay

Any of the above tools can be used, as long as the application still remains active 3 seconds from now. However, if by that time the user has switched to the Home screen or to another app, the scheduled code won’t execute. To understand the reason for that, let’s have a look at the official documentation of the sceneWillResignActive method, which fires when the app becomes inactive:

Use this method to quiet your interface and prepare it to stop interacting with the user. Specifically, pause ongoing tasks, disable timers, and decrease frame rates or stop updating your interface altogether. Games should use this method to pause the game. By the time this method returns, your app should be doing minimal work while it waits to transition to the background or to the foreground again.

“Disable timers”, which is mentioned as one of the prime things to take care of when the app is about to become inactive, will be achieved automatically if you use any of the standard tools mentioned above. In most cases this is good, since you don’t need to worry about disabling the timers. Better still, in most cases the timer will resume when the app becomes active again and the code will finally be executed.

Background Modes

Background tasks in iOS is a well documented topic. There are all sorts of things that application can do when they are not active, here are some:

  1. Deliver notifications
  2. Receive VoIP incoming calls
  3. Track the device location
  4. Play music
  5. Fetch new data from the back-end servers

Adding any of the above capabilities to an application, will require the developer to edit the “Capabilities -> Background Modes” section of the target-settings, and add a new background execution mode. When submitting the app to the store, if a new background mode was added, there is a fair chance that the developer will be asked by the AppStore review team to explain why the app needs this capability. This is especially true when location updates are involved.

In this article I will present a small and compact solution for scheduling a peace of code to run while the app is not active, regardless of what the code does and without the need to change the app’s capabilities. It is important to note that there is another excellent Medium article that proposes a different solution from the one I ended up with:

A Background Repeating Timer in Swift by Daniel Galasko.

The solution in Daniel’s article is good for executing a scheduled task up to 5 seconds from the moment the app becomes inactive. The solution I will propose is good for running tasks up to 30 seconds, which is the max time iOS is giving us for executing a declared background task before a regular app is suspended.

My solution is contained in a class file named BackgroundTimer.swift. In this article I will describe the class and I will also describe the small program I wrote to demonstrate the uses of this class. The class itself and the demo program are available for download from the following public repository:

The BackgroundTimer class

The BackgroundTimer class can be used in different ways:

  1. Schedule something to be executed one time in the future
  2. Schedule something to be executed repeatedly with a regular time interval (for example, every 3 seconds)
  3. After scheduling, a task can be canceled

Since not everybody needs all of the above features, I will present three different versions of this class.

Phase 1: Execute once

This is the most basic implementation:

import UIKit

protocol BackgroundTimerDelegate: AnyObject {
func backgroundTimerTaskExecuted(task: UIBackgroundTaskIdentifier)
}

final class BackgroundTimer {
weak var delegate: BackgroundTimerDelegate?

init(delegate: BackgroundTimerDelegate?) {
self.delegate = delegate
}

func executeAfterDelay(delay: TimeInterval, completion: @escaping(()->Void)) -> UIBackgroundTaskIdentifier {
var backgroundTaskId = UIBackgroundTaskIdentifier.invalid
backgroundTaskId = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(backgroundTaskId) // The expiration Handler
}

// -- The task itself: Wait and then execute --
wait(delay: delay,
backgroundTaskId: backgroundTaskId,
completion: completion
)
return backgroundTaskId
}

private func wait(delay: TimeInterval, backgroundTaskId: UIBackgroundTaskIdentifier, completion: @escaping(()->Void)) {
print("BackgroundTimer: Starting \(delay) seconds countdown")
let startTime = Date()

DispatchQueue.global(qos: .background).async { [weak self] in
// Waiting
while Date().timeIntervalSince(startTime) < delay {
Thread.sleep(forTimeInterval: 0.1)
}

// Executing
DispatchQueue.main.async { [weak self] in
print("BackgroundTimer: \(delay) seconds have passed, executing code block.")
completion()
self?.delegate?.backgroundTimerTaskExecuted(task: backgroundTaskId)
UIApplication.shared.endBackgroundTask(backgroundTaskId) // Clearing
}
}
}
}

As you can see, executeAfterDelay is the only non-private method, so this is the method that should be called by the external code. Here is an example code that uses this method to schedule a device vibration in 3 seconds:

import AudioToolbox
private lazy var timer = BackgroundTimer(delegate: nil)
let taskID = timer.executeAfterDelay(delay: 3) {
AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate))
}

Now let’s have a look at executeAfterDelay.

func executeAfterDelay(delay: TimeInterval, completion: @escaping(()->Void)) -> UIBackgroundTaskIdentifier {
var backgroundTaskId = UIBackgroundTaskIdentifier.invalid
var backgroundTaskId = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(backgroundTaskId) // The expiration Handler
}

// -- The task itself: Wait and then execute --
wait(delay: delay,
backgroundTaskId: backgroundTaskId,
completion: completion
)
return backgroundTaskId
}

The method uses beginBackgroundTask to mark the start of a task that should continue if the app enters the background. The background task now has unique identifier of type UIBackgroundTaskIdentifier. This identifier will be used in order to mark the task as complete once we are done, to let the system know that the app has finished.

Once we have a background task identifier, we continue by calling the following private method:

private func wait(delay: TimeInterval, backgroundTaskId: UIBackgroundTaskIdentifier, completion: @escaping(()->Void)) {
print("BackgroundTimer: Starting \(delay) seconds countdown")
let startTime = Date()

DispatchQueue.global(qos: .background).async { [weak self] in
// Waiting
while Date().timeIntervalSince(startTime) < delay {
Thread.sleep(forTimeInterval: 0.1)
}

// Executing
DispatchQueue.main.async { [weak self] in
print("BackgroundTimer: \(delay) seconds have passed, executing code block.")
completion()
self?.delegate?.backgroundTimerTaskExecuted(task: backgroundTaskId)
UIApplication.shared.endBackgroundTask(backgroundTaskId) // Clearing
}
}
}

In human words, this is what the method does:

  1. Store the current time as startTime
  2. Open a background thread
  3. Wait until the time that has passed between now and startTime is longer than the delay argument
  4. Go back to the Main thread
  5. Execute the task block
  6. Mark the task as complete

This is the basic trick. now let’s continue by adding the ability to repeat a task.

Phase 2: Execute many times

Note: the differences between the previous code and the new code are marked in Bold.

import UIKit

protocol BackgroundTimerDelegate: AnyObject {
func backgroundTimerTaskExecuted(task: UIBackgroundTaskIdentifier, willRepeat: Bool)
}

final class BackgroundTimer {
weak var delegate: BackgroundTimerDelegate?

init(delegate: BackgroundTimerDelegate?) {
self.delegate = delegate
}

func executeAfterDelay(delay: TimeInterval, repeating: Bool, completion: @escaping(()->Void)) -> UIBackgroundTaskIdentifier {
var backgroundTaskId = UIBackgroundTaskIdentifier.invalid
backgroundTaskId = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(backgroundTaskId) // The expiration Handler
}

// -- The task itself: Wait and then execute --
wait(delay: delay,
repeating: repeating,
backgroundTaskId: backgroundTaskId,
completion: completion
)
return backgroundTaskId
}

private func wait(delay: TimeInterval, repeating: Bool, backgroundTaskId: UIBackgroundTaskIdentifier, completion: @escaping(()->Void)) {
print("BackgroundTimer: Starting \(delay) seconds countdown")
let startTime = Date()

DispatchQueue.global(qos: .background).async { [weak self] in
// Waiting
while Date().timeIntervalSince(startTime) < delay {
Thread.sleep(forTimeInterval: 0.1)
}

// Executing
DispatchQueue.main.async { [weak self] in
print("BackgroundTimer: \(delay) seconds have passed, executing code block.")
completion()
self?.delegate?.backgroundTimerTaskExecuted(task: backgroundTaskId, willRepeat: repeating)

if repeating {
if let self {
self.wait(delay: delay,
repeating: repeating,
backgroundTaskId: backgroundTaskId,
completion: completion
)
} else {
print("BackgroundTimer: Failed to repeat, most probably because the BackgroundTimer instance is de-allocated. Make sure you keep a reference to the BackgroundTimer instance in memory.")
UIApplication.shared.endBackgroundTask(backgroundTaskId) // Clearing
}
} else {
UIApplication.shared.endBackgroundTask(backgroundTaskId) // Clearing
}
}
}
}
}

As you can see, a new Bool parameter was added. If the value of repeat is true, the method wait(…) will call itself recursively, after each time the task is executed. This recursion has no end, but it will most probably break once 30 seconds would have passed, because, as stated before, this is the given time window.

Art by coco63

Phase 3: Cancellation

import UIKit

protocol BackgroundTimerDelegate: AnyObject {
func backgroundTimerTaskExecuted(task: UIBackgroundTaskIdentifier, willRepeat: Bool)
func backgroundTimerTaskCanceled(task: UIBackgroundTaskIdentifier)
}

final class BackgroundTimer {
weak var delegate: BackgroundTimerDelegate?
private var tasksToCancel: Set<UIBackgroundTaskIdentifier> = [] // Not thread safe, access with care

init(delegate: BackgroundTimerDelegate?) {
self.delegate = delegate
}

func executeAfterDelay(delay: TimeInterval, repeating: Bool, completion: @escaping(()->Void)) -> UIBackgroundTaskIdentifier {
var backgroundTaskId = UIBackgroundTaskIdentifier.invalid
backgroundTaskId = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(backgroundTaskId) // The expiration Handler
}

// -- The task itself: Wait and then execute --
wait(delay: delay,
repeating: repeating,
backgroundTaskId: backgroundTaskId,
completion: completion
)
return backgroundTaskId
}

func cancelExecution(tasks: [UIBackgroundTaskIdentifier]) {
guard Thread.isMainThread else {
return assertionFailure()
}

tasks.forEach {
tasksToCancel.insert($0)
}
}

private func wait(delay: TimeInterval, repeating: Bool, backgroundTaskId: UIBackgroundTaskIdentifier, completion: @escaping(()->Void)) {
guard Thread.isMainThread else {
return assertionFailure()
}

guard !tasksToCancel.contains(backgroundTaskId) else {
return cancel(backgroundTaskId: backgroundTaskId)
}

print("BackgroundTimer: Starting \(delay) seconds countdown")
let startTime = Date()

DispatchQueue.global(qos: .background).async { [weak self] in
// Waiting
while Date().timeIntervalSince(startTime) < delay {
Thread.sleep(forTimeInterval: 0.1)
}

// Executing
DispatchQueue.main.async { [weak self] in
guard !(self?.tasksToCancel ?? []).contains(backgroundTaskId) else {
self?.cancel(backgroundTaskId: backgroundTaskId)
return
}

print("BackgroundTimer: \(delay) seconds have passed, executing code block.")
completion()
self?.delegate?.backgroundTimerTaskExecuted(task: backgroundTaskId, willRepeat: repeating)

if repeating {
if let self {
self.wait(delay: delay,
repeating: repeating,
backgroundTaskId: backgroundTaskId,
completion: completion
)
} else {
print("BackgroundTimer: Failed to repeat, most probably because the BackgroundTimer instance is de-allocated. Make sure you keep a reference to the BackgroundTimer instance in memory.")
UIApplication.shared.endBackgroundTask(backgroundTaskId) // Clearing
}
} else {
UIApplication.shared.endBackgroundTask(backgroundTaskId) // Clearing
}
}
}
}

private func cancel(backgroundTaskId: UIBackgroundTaskIdentifier) {
guard Thread.isMainThread else {
return assertionFailure()
}
print("BackgroundTimer: Aborting task \(backgroundTaskId)")
UIApplication.shared.endBackgroundTask(backgroundTaskId)
delegate?.backgroundTimerTaskCanceled(task: backgroundTaskId)
tasksToCancel.remove(backgroundTaskId)
}
}

When the external code calls cancelExecution(…), the identifiers of the tasks that should be cancelled are added to the tasksToCancel list. When the class is about to execute a scheduled task, it looks for the task identifier in the list, to make sure the task hasn’t been cancelled. This list is implemented as a Set and not as an array, for two reasons:

  1. The order of the elements is not important
  2. Finding an element in a set is faster

The tasksToCancel set is not thread-safe. Since the program uses different threads for different things, it is important to protect the set from data races. A data race can be created if the external code adds an item to cancel at exactly the same time in which the class reads the list (when checking if an item is cancelled) or modifies it (when removing an item that has already been cancelled). The two most common and recommemded ways to protect a resource from data races are using a Dispatch Barrier, or an Actor.

I have tried to isolate the access to this property by using a Dispatch Queue, and by using an Actor, but both attempts have created many bugs and unpredicted behaviors. Eventually I settled on leaving the program as it is and just making sure that the tasksToCancel Set is always accessed from the Main thread. This is of course not ideal, and if by any chance you would like to offer a more thread-safe code It would be greatly appreciated.

The Demo

The demo program has one screen. This is how it looks on the Storyboard:

And this is how it looks on my phone:

Whenever the Go button is tapped, a new scheduled device-vibration is added, based on the supplied parameters. This is indicated visually by adding a new “Waiting to execute (tap to cancel)” label. When the task is executed or canceled, the label disappears. If the task is a repeating task, the label will stay on screen until the task is canceled.

As I mentioned before, the program code, including the view controller and the Background Timer class itself, is available for download from the following public repository:

--

--