axiom-timer-patterns

Timer Safety Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "axiom-timer-patterns" with this command: npx skills add charleswiltgen/axiom/charleswiltgen-axiom-axiom-timer-patterns

Timer Safety Patterns

Overview

Timer-related crashes are among the hardest to diagnose because they're often intermittent and the crash log points to GCD internals, not your code. Core principle: DispatchSourceTimer has a state machine — violating it causes deterministic EXC_BAD_INSTRUCTION crashes that look random. Timer (NSTimer) has a RunLoop mode trap that silently stops your timer during scrolling. Both are preventable with the patterns in this skill.

Example Prompts

  • "My timer stops when the user scrolls"

  • "EXC_BAD_INSTRUCTION crash in my timer code"

  • "Should I use Timer or DispatchSourceTimer?"

  • "How do I safely cancel a DispatchSourceTimer?"

  • "My DispatchSourceTimer crashes on dealloc"

  • "Timer keeps running after I dismiss the view controller"

Part 1: Timer vs DispatchSourceTimer Decision Tree

Feature Timer DispatchSourceTimer AsyncTimerSequence

Thread safety Main thread only (RunLoop-bound) Any queue (you choose) Task-bound (structured concurrency)

Scrolling survival Only in .common mode Always (no RunLoop dependency) Always (no RunLoop dependency)

Precision Low (RunLoop coalescing) High (GCD scheduling) Medium (clock-dependent)

Lifecycle complexity Low (invalidate + nil) High (state machine, 4 crash patterns) Low (task cancellation)

iOS version 2.0+ 8.0+ (GCD) 16.0+

Use case UI updates on main thread Background work, precise timing, custom queues Modern async code, structured concurrency

Quick Decision

Need a simple UI update timer? ├─ Yes → Timer (with .common RunLoop mode) │ Need precise timing or background queue? ├─ Yes → DispatchSourceTimer (with SafeDispatchTimer wrapper) │ Writing modern async/await code on iOS 16+? ├─ Yes → AsyncTimerSequence (ContinuousClock.timer) │ Need Combine integration? └─ Yes → Timer.publish

Part 2: RunLoop Mode Gotcha

Timer stops firing during scrolling. This is the single most common timer bug in iOS development.

Why It Happens

Timer.scheduledTimer adds the timer to the current RunLoop in .default mode. When the user scrolls (UIScrollView, SwiftUI ScrollView, List), the RunLoop switches to .tracking mode. The timer doesn't fire in .tracking mode because it was only registered for .default .

Time cost: Timer mysteriously stops during scroll → 30+ min debugging if you don't know about RunLoop modes.

❌ Broken — Timer stops during scrolling

// BAD: Timer added to .default mode (implicit) let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateProgress() } // Timer STOPS when user scrolls any UIScrollView or SwiftUI List

✅ Fixed — Timer survives scrolling

// GOOD: Explicitly add to .common mode (includes both .default and .tracking) let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in self?.updateProgress() } RunLoop.current.add(timer, forMode: .common)

✅ Fixed — Combine Timer survives scrolling

// GOOD: Timer.publish with .common mode — survives scrolling in SwiftUI Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.updateProgress() } .store(in: &cancellables)

Key: The in: parameter defaults to .default if omitted — always specify .common explicitly.

RunLoop Modes

Mode When Active Timer Fires?

.default

Normal interaction Yes

.tracking

During scrolling Only if added to .common

.common

Pseudo-mode: includes .default

  • .tracking

Yes (always)

Part 3: The 4 DispatchSourceTimer Crash Patterns

Each of these causes EXC_BAD_INSTRUCTION — a crash that points to GCD internals, making it hard to trace back to your timer code.

Crash Frame → Pattern Mapping

When you see EXC_BAD_INSTRUCTION in a crash log, match the top frame:

Top Crash Frame Crash Pattern Fix

dispatch_source_cancel

Crash 2: Cancel while suspended resume() before cancel()

_dispatch_source_dispose

Crash 3: Dealloc while suspended Resume + cancel before releasing

dispatch_resume

Crash 4: Resume after cancel Check isCancelled before operating

_dispatch_source_refs_t / suspend count

Crash 1: Unbalanced suspend Track state, only suspend if running

DispatchSourceTimer State Machine

                activate()
    idle ──────────────► running
                           │  ▲
                suspend()  │  │  resume()
                           ▼  │
                        suspended
                           │
                resume() + cancel()
                           │
                           ▼
                       cancelled (terminal)

CRASH ZONES: suspended → cancel() = EXC_BAD_INSTRUCTION suspended → dealloc = EXC_BAD_INSTRUCTION suspended → suspend() = suspend count underflow on dealloc cancelled → resume() = EXC_BAD_INSTRUCTION

Crash 1: Suspend While Already Suspended

Calling suspend() multiple times without matching resume() calls. Each suspend() increments an internal counter. On dealloc, if the suspend count isn't zero, GCD crashes.

❌ Crash

let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now(), repeating: 1.0) timer.setEventHandler { doWork() } timer.activate()

// User triggers pause twice rapidly timer.suspend() // suspend count = 1 timer.suspend() // suspend count = 2

timer.resume() // suspend count = 1 // Timer deallocated with suspend count = 1 → EXC_BAD_INSTRUCTION

✅ Safe

// Track state — only suspend if running var isRunning = true

func pause() { guard isRunning else { return } timer.suspend() isRunning = false }

func unpause() { guard !isRunning else { return } timer.resume() isRunning = true }

Crash 2: Cancel While Suspended

GCD requires a dispatch source to be in a non-suspended state before cancellation. Cancelling a suspended timer crashes immediately.

❌ Crash

let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now(), repeating: 1.0) timer.setEventHandler { doWork() } timer.activate()

timer.suspend() timer.cancel() // EXC_BAD_INSTRUCTION — can't cancel while suspended

✅ Safe

// ALWAYS resume before cancelling timer.resume() // Move out of suspended state timer.cancel() // Now safe to cancel

Crash 3: Dealloc While Suspended

Setting the timer to nil (or letting it go out of scope) while suspended. Deallocation internally attempts cleanup that fails on a suspended source.

❌ Crash

var timer: DispatchSourceTimer?

func startTimer() { timer = DispatchSource.makeTimerSource(queue: queue) timer?.schedule(deadline: .now(), repeating: 1.0) timer?.setEventHandler { [weak self] in self?.doWork() } timer?.activate() }

func pauseTimer() { timer?.suspend() }

func cleanup() { timer = nil // Dealloc while suspended → EXC_BAD_INSTRUCTION }

✅ Safe

func cleanup() { // Resume before releasing timer?.resume() timer?.cancel() timer = nil // Now safe — timer is in cancelled state }

Crash 4: Operate After Cancel

Calling resume() or suspend() on a cancelled timer. Cancellation is a terminal state — the timer cannot be reused.

❌ Crash

timer.cancel() timer.resume() // EXC_BAD_INSTRUCTION — can't resume a cancelled source

✅ Safe

// Track cancellation state var isCancelled = false

func cancel() { guard !isCancelled else { return } timer.cancel() isCancelled = true }

func resume() { guard !isCancelled else { return } // Check before operating timer.resume() }

Part 4: SafeDispatchTimer Wrapper

Copy-paste this class to prevent all 4 crash patterns. State machine enforces valid transitions.

final class SafeDispatchTimer { enum State { case idle, running, suspended, cancelled }

private(set) var state: State = .idle
private let timer: DispatchSourceTimer

init(queue: DispatchQueue = DispatchQueue(label: "safe-dispatch-timer")) {
    timer = DispatchSource.makeTimerSource(queue: queue)
}

func schedule(interval: TimeInterval, handler: @escaping () -> Void) {
    guard state == .idle else { return }
    timer.schedule(deadline: .now() + interval, repeating: interval)
    timer.setEventHandler(handler: handler)
    timer.activate()
    state = .running
}

func suspend() {
    guard state == .running else { return }
    timer.suspend()
    state = .suspended
}

func resume() {
    guard state == .suspended else { return }
    timer.resume()
    state = .running
}

func cancel() {
    switch state {
    case .suspended:
        timer.resume()  // Must resume before cancel
        timer.cancel()
    case .running:
        timer.cancel()
    case .idle, .cancelled:
        return
    }
    state = .cancelled
}

deinit {
    cancel()  // Safe cleanup regardless of current state
}

}

Usage

class BackgroundPoller { private var timer: SafeDispatchTimer?

func start() {
    timer = SafeDispatchTimer()
    timer?.schedule(interval: 5.0) { [weak self] in
        self?.fetchData()
    }
}

func pause() {
    timer?.suspend()  // Safe — no-op if not running
}

func unpause() {
    timer?.resume()  // Safe — no-op if not suspended
}

func stop() {
    timer?.cancel()  // Safe — handles any state
    timer = nil
}

}

Part 5: Thread Safety

Always Use a Dedicated Serial Queue

DispatchSourceTimer fires its event handler on the queue you specify at creation. Using a concurrent queue creates race conditions when multiple firings overlap or when you modify shared state from the handler.

❌ Race Condition

// BAD: Concurrent queue — handler can fire while previous invocation is still running let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) timer.setEventHandler { self.count += 1 // Race condition self.processItem(count) // Overlapping invocations }

✅ Serial Queue

// GOOD: Dedicated serial queue — handler invocations are serialized let timerQueue = DispatchQueue(label: "com.app.timer-queue") let timer = DispatchSource.makeTimerSource(queue: timerQueue) timer.setEventHandler { [weak self] in self?.count += 1 // Safe — serial queue self?.processItem(count) // No overlap }

Main Queue for UI Updates

If your timer handler updates UI, dispatch to main:

let timer = DispatchSource.makeTimerSource(queue: timerQueue) timer.setEventHandler { [weak self] in let result = self?.computeResult() DispatchQueue.main.async { self?.updateUI(with: result) } }

Part 6: Anti-Patterns

Anti-Pattern Time Cost Fix

Timer in .default RunLoop mode 30+ min debugging scroll freeze Use .common mode

No state tracking on DispatchSourceTimer EXC_BAD_INSTRUCTION crash, hours to diagnose Use SafeDispatchTimer wrapper

timer.cancel() while suspended Production crash resume() then cancel()

Timer on .global() queue Race conditions, intermittent crashes Dedicated serial queue

Force-unwrapping timer Crash if timer already cancelled Optional check or state enum

Not clearing event handler before cancel Potential retain cycle timer.setEventHandler(handler: nil) then cancel

Timer retains target (selector API) Memory leak — deinit never called Use block API with [weak self]

Creating timer without invalidating previous Timer accumulation, CPU waste Always invalidate/cancel before creating new

Timer on background thread without RunLoop Timer silently never fires Timer requires a RunLoop — use DispatchSourceTimer or AsyncTimerSequence for background work

Part 7: Pressure Scenarios

Scenario 1: "Just use Timer.scheduledTimer and move on"

Setup: Deadline approaching, need a repeating update every second.

Pressure: Timer is simpler than DispatchSourceTimer. "It's just a UI update timer, no need for GCD complexity."

Expected with skill: Choose Timer for simple UI updates — but add it to .common RunLoop mode so it survives scrolling. Only reach for DispatchSourceTimer when you need precision, background execution, or a custom queue.

Anti-pattern without skill: Using Timer.scheduledTimer with default .default mode → timer stops during scrolling → user reports "progress bar freezes when I scroll" → 30+ min debugging.

Pushback template: "Timer is the right choice for a UI update, but we need to add it to .common RunLoop mode. Without that, the timer stops every time the user scrolls. It's a 2-line change that prevents a guaranteed bug report."

Scenario 2: "The crash only happens sometimes, let's ship and fix later"

Setup: EXC_BAD_INSTRUCTION in production crash logs. Can't reproduce reliably in development.

Pressure: "It's rare. Users can reopen the app. We'll fix it in the next release."

Expected with skill: Recognize the crash signature as a DispatchSourceTimer state machine violation. All 4 crash patterns are deterministic — they happen every time the specific state transition occurs. The "intermittent" appearance comes from the state transition being timing-dependent, not the crash itself. Apply SafeDispatchTimer wrapper.

Anti-pattern without skill: Shipping without fix → crash rate compounds with user count → crash appears in App Store review metrics → rejection risk.

Pushback template: "This crash is deterministic — it happens every time the timer is in a specific state. The 'intermittent' part is just the timing of when that state occurs. SafeDispatchTimer is a drop-in replacement that eliminates all 4 crash patterns. It's a 15-minute fix that prevents a production crash."

Scenario 3: "Timer.invalidate() handles cleanup"

Setup: Timer being used in a view controller, calling invalidate() in deinit .

Pressure: "invalidate() is the standard cleanup pattern. It's in every tutorial."

Expected with skill: Recognize the retain cycle: Timer.scheduledTimer(timeInterval:target:selector:) retains its target. If the target is self (the view controller), and the view controller holds a strong reference to the timer, you have a retain cycle. deinit never gets called because the timer keeps self alive. Solution: use [weak self] with the block API, and invalidate in viewWillDisappear (not deinit ).

Anti-pattern without skill: Timer retains self → deinit never called → invalidate never called → timer keeps firing → memory leak + accumulating timers → eventual crash or battery drain.

Pushback template: "The block-based Timer API with [weak self] is the fix. The selector-based API retains its target, which means our deinit never fires and invalidate() never gets called. We also need to move invalidate() to viewWillDisappear as a safety net."

Related Skills

  • axiom-timer-patterns-ref — API reference for Timer, DispatchSourceTimer, Combine Timer.publish, AsyncTimerSequence with lifecycle diagrams and platform availability

  • axiom-memory-debugging — Timer as Pattern 1 memory leak (Timer retains target, RunLoop retains Timer)

  • axiom-energy — Timer as energy drain pattern (tolerance, coalescing, event-driven alternatives)

Resources

WWDC: 2017-706

Skills: timer-patterns-ref, memory-debugging, energy, energy-ref

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

axiom-vision

No summary provided by upstream source.

Repository SourceNeeds Review
General

axiom-swiftdata

No summary provided by upstream source.

Repository SourceNeeds Review
General

axiom-swiftui-26-ref

No summary provided by upstream source.

Repository SourceNeeds Review
General

axiom-swiftui-architecture

No summary provided by upstream source.

Repository SourceNeeds Review