Use when implementing timers, debugging timer crashes (EXC_BAD_INSTRUCTION), Timer stops during scrolling, or choosing between Timer/DispatchSourceTimer/Combine/async timer APIs
Helps implement safe timers by preventing scrolling freezes and EXC_BAD_INSTRUCTION crashes.
npx claudepluginhub charleswiltgen/axiomThis skill inherits all available tools. When active, it can use any tool Claude has access to.
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.
| 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 |
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
Timer stops firing during scrolling. This is the single most common timer bug in iOS development.
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.
// 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
// 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)
// 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.
| 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) |
Each of these causes EXC_BAD_INSTRUCTION — a crash that points to GCD internals, making it hard to trace back to your timer code.
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 |
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
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.
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
// 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
}
GCD requires a dispatch source to be in a non-suspended state before cancellation. Cancelling a suspended timer crashes immediately.
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
// ALWAYS resume before cancelling
timer.resume() // Move out of suspended state
timer.cancel() // Now safe to cancel
Setting the timer to nil (or letting it go out of scope) while suspended. Deallocation internally attempts cleanup that fails on a suspended source.
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
}
func cleanup() {
// Resume before releasing
timer?.resume()
timer?.cancel()
timer = nil // Now safe — timer is in cancelled state
}
Calling resume() or suspend() on a cancelled timer. Cancellation is a terminal state — the timer cannot be reused.
timer.cancel()
timer.resume() // EXC_BAD_INSTRUCTION — can't resume a cancelled source
// 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()
}
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
}
}
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
}
}
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.
// 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
}
// 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
}
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)
}
}
| 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 |
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."
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."
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."
axiom-timer-patterns-ref — API reference for Timer, DispatchSourceTimer, Combine Timer.publish, AsyncTimerSequence with lifecycle diagrams and platform availabilityaxiom-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)WWDC: 2017-706
Skills: timer-patterns-ref, memory-debugging, energy, energy-ref
Activates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.
Search, retrieve, and install Agent Skills from the prompts.chat registry using MCP tools. Use when the user asks to find skills, browse skill catalogs, install a skill for Claude, or extend Claude's capabilities with reusable AI agent components.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.