skills/charleswiltgen/axiom/axiom-timer-patterns

axiom-timer-patterns

SKILL.md

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

Weekly Installs
33
GitHub Stars
625
First Seen
Feb 27, 2026
Installed on
opencode31
github-copilot31
codex31
kimi-cli31
amp31
cline31