The Problem With Naive Timers
When I started building BoxTime, my boxing round timer app, I made the classic mistake: I used Timer.scheduledTimer with a 1-second interval and decremented a counter. It looked right in the simulator. It looked right for the first 30 seconds on a real device. Then the drift crept in.
After a 3-minute round, my timer was off by 1-2 seconds. Over a full 12-round session, that adds up. In boxing, timing matters. You can't have a round timer that lies to you.
Why Timers Drift
The fundamental issue is that Timer in iOS is not a precision instrument. It fires on the run loop, and the run loop has other things to do. Each tick might be 1.002 seconds instead of 1.000. Those fractions accumulate.
// The naive approach - DO NOT use this for precision
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.remainingSeconds -= 1
}
The run loop might delay a fire by milliseconds. When the UI is busy, it gets worse. Scrolling, animations, haptics -- all of it competes for time on the main thread.
The Fix: Anchor to Absolute Time
Instead of counting ticks, I anchor to a reference point using Date and calculate the elapsed time on every update. The display timer fires frequently, but the actual remaining time is always computed from reality.
@Observable
class RoundTimer {
private var roundEndTime: Date?
private var displayLink: CADisplayLink?
var remainingSeconds: TimeInterval {
guard let end = roundEndTime else { return 0 }
return max(0, end.timeIntervalSinceNow)
}
func startRound(duration: TimeInterval) {
roundEndTime = Date().addingTimeInterval(duration)
startDisplayUpdates()
}
private func startDisplayUpdates() {
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func tick() {
// View updates automatically via @Observable
if remainingSeconds <= 0 {
roundDidEnd()
}
}
}
Why CADisplayLink Over Timer
I use CADisplayLink instead of a repeating Timer for the display updates. Two reasons:
- It syncs to the screen refresh rate. No point updating faster than the display can render.
-
Using
.commonrun loop mode means it keeps firing during scrolling and other UI interactions. A default-mode Timer pauses when the user touches the screen -- terrible for a workout app.
The Remaining Subtlety
Even with absolute time anchoring, there is a display quantization issue. If your UI shows whole seconds, you need to decide: do you show the ceiling or the floor? I chose ceiling. When remainingSeconds is 2.3, the display shows "3". This means the display changes from "1" to "0" right as time actually expires, which feels correct to users.
var displaySeconds: Int {
Int(ceil(remainingSeconds))
}
Results
After this refactor, BoxTime's timer is accurate to within a single frame (~16ms at 60fps). Over a 36-minute session, the cumulative error is zero because there is no accumulation -- every frame recalculates from the absolute endpoint.
If you are building any kind of countdown or stopwatch in SwiftUI, anchor to real time. Never count ticks. Your users might not notice 2 seconds of drift, but they will notice 10. And if they are getting punched in the face, they will definitely notice.
BoxTime is free on the App Store if you want to see this in action.
Top comments (0)