SwiftUI Timer: How to Repeat Without RunLoop Bugs

SwiftUI Timer: How to Repeat Without RunLoop Bugs

Written by

in

Quick answer: Use TimelineView(.periodic(from:by:)) when the timer only drives UI, and Task.sleep inside .task when it drives state — the task is cancelled automatically when the view goes away. Keep Timer plus RunLoop for non-view code, and use Timer.publish with onReceive if the project already uses Combine.

Why this happens

Timer comes from Foundation and predates SwiftUI by a long way. It has no idea a view exists: you create it, hand it to a run loop, and it keeps firing until something calls invalidate(). Inside a view that means the timer outlives the view unless you remember to cancel it in onDisappear, and the closure it holds keeps your state alive in the meantime.

Adding it to RunLoop.main in .common mode makes it fire during scrolling and touch tracking — exactly when you least want extra work on the main thread. And because Timer is callback-based, it does not participate in structured concurrency: no cancellation propagation, no await, no actor isolation.

None of that makes Timer wrong. It just makes it a poor default inside a SwiftUI view. Every alternative below ties the ticking to the view’s lifetime instead of to a run loop.

How to fix it

1. Use TimelineView when the timer only drives drawing

This is the most natural SwiftUI answer. You do not run a closure — SwiftUI re-evaluates your view body on a schedule and hands you the scheduled date in context.date.

import SwiftUI

struct ClockView: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 1)) { context in
            Text(context.date, format: .dateTime.hour().minute().second())
                .monospacedDigit()
        }
    }
}

Because the update is part of the view graph, SwiftUI stops asking for updates when the view is off screen, and the system can throttle the cadence under load. Treat the interval as a hint, not a metronome.

Other built-in schedules: .everyMinute, .animation (display-synced, the SwiftUI replacement for CADisplayLink), and .explicit(_:) for a fixed list of dates. Explicit schedules with several timestamps have had reported reliability problems, so if a custom schedule misbehaves, fall back to .periodic or one of the patterns below.

2. Use Task.sleep inside .task when the timer changes state

If each tick mutates @State, writes to SwiftData, or calls a service, TimelineView is the wrong tool — its body must stay cheap and side-effect free. Use an async loop instead. The .task modifier starts the work when the view appears and cancels it when the view disappears.

import SwiftUI

struct CountdownView: View {
    @State private var remaining = 60

    var body: some View {
        Text("\(remaining)s")
            .task {
                while remaining > 0 {
                    do {
                        try await Task.sleep(for: .seconds(1))
                    } catch {
                        break // cancelled: the view went away
                    }
                    remaining -= 1
                }
            }
    }
}

The do/catch matters. Task.sleep throws CancellationError when the task is cancelled, and swallowing it with try? lets one extra tick run after the view is gone.

To restart the loop when an input changes, use .task(id:) — for example .task(id: scenePhase) with a guard scenePhase == .active else { return } at the top, so the countdown pauses when the app is backgrounded.

3. Use AsyncTimerSequence for AsyncSequence code

If the project already models events as async sequences, the Swift Async Algorithms package gives you a timer that composes like any other sequence. Add the package, then:

import SwiftUI
import AsyncAlgorithms

struct PulseView: View {
    @State private var ticks = 0

    var body: some View {
        Text("\(ticks)")
            .task {
                for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
                    ticks += 1
                }
            }
    }
}

The for await loop ends when the task is cancelled, so the same view-lifetime rule applies. It also composes with merge, debounce and the rest of the package, which is the reason to reach for it over a bare while loop.

4. Use Timer.publish with onReceive in Combine code

If the surrounding code is already Combine-based, keep the publisher and let SwiftUI manage the subscription:

import SwiftUI
import Combine

struct TickerView: View {
    @State private var count = 0
    private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        Text("\(count)")
            .onReceive(ticker) { _ in count += 1 }
    }
}

onReceive subscribes when the view appears and cancels when it disappears, so you get the lifecycle behaviour that a raw RunLoop timer lacks. One caveat: the publisher is a stored property of the view struct, so recreating the view restarts the interval. If you see the cadence reset, move the publisher into an ObservableObject and expose it from there.

5. Handle Focus and scene state explicitly

No timer API knows about the system Focus mode. If the ticking should stop when the user turns on a Focus, you have to tell your app about it: apps opt in through a SetFocusFilterIntent from App Intents, which the system calls when the Focus changes. Store the result and gate your loop on it.

For ordinary backgrounding, @Environment(\.scenePhase) is enough — combine it with .task(id:) as shown above, or check it inside the loop before doing work.

How to prevent it

  • Never add a Timer to RunLoop.main in onAppear without a matching invalidate() in onDisappear.
  • Prefer .task over onAppear plus a timer — cancellation is automatic and propagates.
  • Compute elapsed time from stored Date values instead of counting ticks; throttled or dropped ticks then cannot drift your UI.
  • Use .task(id:) to restart work when an input changes, rather than juggling manual start/stop calls.
  • Do not drive animation from a timer. Use withAnimation or TimelineView(.animation).
  • Keep TimelineView bodies cheap: format a date, do not fetch or write.

FAQ

Is Timer.scheduledTimer still usable in SwiftUI?

It compiles and runs, but it is not tied to the view lifecycle, so it keeps firing after the view is dismissed unless you invalidate it yourself. Use it only in code that is not view-scoped, such as a background service or a model object that owns its own lifetime.

Why does my TimelineView update slower than the interval I set?

The schedule is a request, not a guarantee. The system throttles updates for views that are not visible and under load, and it may coalesce them. If you need accurate elapsed time, store a start Date and derive the display from Date.now.timeIntervalSince(start) instead of incrementing a counter per update.

Can SwiftUI read the system Focus mode directly?

No — there is no environment value for it. Apps receive Focus configuration through a SetFocusFilterIntent in App Intents, which the system calls when the Focus changes; you persist that value and react to it in your own state. @Environment(\.isFocused) is unrelated: it reports keyboard or focus-engine focus, not Do Not Disturb.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.