Swift’s Scheduled Timer — Defusing a time bomb

Modernizing Swift’s Timer API

Axel Ancona Esselmann
6 min readJul 16, 2024

We have a leak…

The following View and ViewModel implementations for displaying a simple Timer can leak memory:

struct TimerView: View {

@StateObject
private var vm = TimerViewModel()

var body: some View {
VStack {
Button(
vm.isInactive ? "start" : "stop",
action: vm.isInactive ? vm.startTimer : vm.stopTimer
)
Text(String(vm.elapsed))
}
}
}

class TimerViewModel: ObservableObject {

@Published
private(set) var elapsed: Int = 0

var isInactive: Bool {
timer == nil
}

private var timer: Timer?

func startTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(
withTimeInterval: 1,
repeats: true
) { [weak self] timer in
self?.elapsed += 1
}
}

func stopTimer() {
timer?.invalidate()
timer = nil
objectWillChange.send()
}
}

On 9 out of 10 days I can stare at that code and would not be able to spot an issue. If that is you and you’d like to try your hands in debugging see the example project here. (For a UIKit version go here)

Since SwiftUI Views are value-types TimerViewModel, which is a reference-type, must be the culprit. So under what circumstances does TimerViewModel leak memory and why might it be difficult to spot? As the title suggests: the issue lies within the Timer API. Command-clicking on scheduledTimer(withTimeInterval:repeats:block) yields a subtle hint:

- parameter: repeats If YES, the timer will repeatedly reschedule itself until invalidated.

We do call invalidate() when the Timer is stopped. And when we create a Timer we call invalidate() on any potentially pre-existing instances… What else is there?

If all our app did was display TimerView we don’t have an issue. But when presented by another View, TimerViewModel has the potential to leak memory. Let’s take a look at the example app. Here we have ContentView displaying TimerView. Additionally a Button can hide TimerView:

struct ContentView: View {

@State
private var timerIsShowing = true

var body: some View {
VStack {
if timerIsShowing {
TimerView()
}
Button(timerIsShowing ? "Hide timer" : "Show timer") {
timerIsShowing = !timerIsShowing
}
}
}
}

This is where we get into trouble. Starting and then hiding a Timer will cause our TimerViewModel to get deinitialized. Without a call to invalidate(). If we show TimerView again we will get a fresh instance of TimerViewModel with its timer property initialized to nil. The Timer we started previously will continue to re-schedule itself on RunLoop, which has a strong reference to our Timer instance but we no longer have any references to it. Adding a bit of logging inside Timer’s block closure reveals the issue:

Timer does not get invalidated when TimerViewModel is deinitialized

Note: Had we captured self not weakly but with unowned our app would have crashed the first time our Timer-instance fired after the TimerViewModel instance that created it was deinitialized.

Manually invalidating on deinit

To stop leaking Timer-instances (and potentially perform work during each re-scheduled RunLoop-cycle) we could add a call to invalidate() in TimerViewModel’s deinitializer:

class TimerViewModel: ObservableObject {

deinit {
timer?.invalidate()
}
// ...
}

While the above works, it has a significant drawback: We are adding “cleanup code” far away from where we create our Timer instance, which makes it difficult to maintain.

Introducing AnyCancellable

Swift’s Combine framework uses AnyCancellable to tie “cleanup code” to the deinitialization of a parent object. Let’s create our own version:

final class TypeErasedCancellable {

private var onDeinit: () -> Void

init(_ cancel: @escaping () -> Void) {
self.onDeinit = cancel
}

deinit {
onDeinit()
}
}

Note: If your your project already uses Combine it makes sense to use AnyCancellable instead of TypeErasedCancellable.

Just like in TimerViewModel we perform “cleanup code” when the instance gets released from memory. The difference is that now we get to pass in the “cleanup code” in form of a closure when we create a TypeErasedCancellable instance.

Here is a first pass at refactoring TimerViewModel:

class TimerViewModel: ObservableObject {

@Published
private(set) var elapsed: Int = 0

var isInactive: Bool {
cancellable == nil
}

private var cancellable: TypeErasedCancellable?

func startTimer() {
let timer = Timer.scheduledTimer(
withTimeInterval: 1,
repeats: false
) { [weak self] _ in
self?.elapsed += 1
}
cancellable = TypeErasedCancellable({
timer.invalidate()
})
}

func stopTimer() {
cancellable = nil
}
}

Instead of holding onto our Timer with a property, we create a TypeErasedCancellable property that contains our cleanup code. Now all the work to invalidate Timer is physically close to its instantiation, which allows us to see the entire lifecycle of our Timer-instance in one place. To stop Timer we now just have to release our cancellable from memory by setting it to nil. As long as we didn’t create a retain cycle by directly or indirectly creating a strong reference to self the cancellable will get deinitialized, during which invalidate() gets called on Timer, which in turn will not reschedule itself on the RunLoop; releasing all references to our Timer.

The same will happen when our TimerViewModel gets deinitialized, which is exactly what we were after! Logging confirms this:

Timer gets invalidated when TimerViewModel gets deinitialized

Note: We are not guaranteed that the TimerViewModel instance gets deallocated immediately when its View is no longer visible. If Timer’s block closure had side-effects we would also have to manually invalidate Timer in onDisappear(perform:).

There are a couple of improvements we can still make.

Making dependencies explicit

I personally don’t like that the creation of a Timer has side effects on another object (the RunLoop) that can hold a strong reference to our Timer. This hides coupling and to an extend contributed to the initial difficulty of spotting the memory leak. Let’s improve Timer’s API and remove hidden dependencies and potential retain-cycles:

extension Timer {
func schedule(
on runLoop: RunLoop = .main,
forMode mode: RunLoop.Mode = .default,
onCanceled: (() -> Void)? = nil
) -> TypeErasedCancellable {
runLoop.add(self, forMode: mode)
return TypeErasedCancellable { [weak self] in
self?.invalidate()
onCanceled?()
}
}
}

Timer now has a schedule() method that returns a TypeErasedCancellable and makes it explicit we are interacting with RunLoop. We also have the option to pass in some work we want to perform when a timer is cancelled. Starting a Timer now looks like this:

class TimerViewModel: ObservableObject {

// ...

func startTimer() {
cancellable = Timer(
timeInterval: 1,
repeats: true
) { [weak self] _ in
self?.elapsed += 1
}.schedule()
}
}

Multiple TypeErasedCancellables

We can learn one last lesson from AnyCancellable; if we want multiple cancellables released during deinitialization it would be convenient to be able to maintain them in a Collection. Combine uses a Set, so let’s copy that approach.

TypeErasedCancellable will have to conform to Hashable:

extension TypeErasedCancellable: Hashable {

private var identifier: ObjectIdentifier {
ObjectIdentifier(self)
}

static func == (lhs: TypeErasedCancellable, rhs: TypeErasedCancellable) -> Bool {
lhs.identifier == rhs.identifier
}

func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}
}

We can now extend TypeErasedCancellable with a method for inserting into a Set of TypeErasedCancellables:

extension TypeErasedCancellable {
func store(in cancellables: inout Set<TypeErasedCancellable>) {
cancellables.insert(self)
}
}

or we could implement a custom += operator for a Set of TypeErasedCancellables:

extension Set where Element == TypeErasedCancellable {
static func += (lhs: inout Self, rhs: Element) {
lhs.insert(rhs)
}
}

ViewModels and @MainActor

I consider it best practice to use the @MainActor attribute for view-models. Timer’s run block is a sendable closure. Under Swift 6 properties that are @MainActor-isolated cannot be referenced from sendable closures. I use these convenience methods on Timer for that purpose:

extension Timer {

@MainActor
convenience init(
every timeInterval: Double,
repeat block: @escaping @MainActor () -> Void
) {
self.init(timeInterval: timeInterval, repeats: true) { _ in
Task { @MainActor in block() }
}
}

@MainActor
convenience init(
once timeInterval: Double,
repeat block: @escaping @MainActor () -> Void
) {
self.init(timeInterval: timeInterval, repeats: false) { _ in
Task { @MainActor in block() }
}
}
}

The completed project can be found here.

A final poll:

Thanks so much for reading. If you enjoyed this article consider following me on threads @DudeOnSwift.

--

--

Axel Ancona Esselmann

Senior IOS engineer at 23andMe, developed and teaches course on IOS engineering at San Francisco State University