Why does Timer.publish emit nothing until the run loop runs, and which run-loop mode should it use?

11.9.4 · L2 · Interview

Predict first

What happens before and after the run loop runs?

import Combine
import Foundation

var bag = Set<AnyCancellable>()
var ticks = 0
Timer.publish(every: 0.05, on: .main, in: .common).autoconnect().sink { _ in
    ticks += 1
    if ticks == 3 { print("3 ticks"); exit(0) }
}.store(in: &bag)
print("before the run loop runs, ticks:", ticks)
RunLoop.main.run(until: Date().addingTimeInterval(2))
print("timed out")
  • before the run loop runs, ticks: 0 / 3 ticks
  • 3 ticks / before the run loop runs, ticks: 3
  • before the run loop runs, ticks: 0 / timed out

Mental model. A timer is an alarm clock that only rings if someone is in the room to hear it: the run loop is that someone.

Timer.publish(every:on:in:) schedules a real Timer on a run loop in a mode, and timers are only fired by a running run loop. In the check the subscription is set up and ticks is still 0 on the next line, because the timer fires later, when RunLoop.main.run processes it; in a command-line tool or a test with no running run loop you would see nothing at all. In an app the main run loop is always running, so this is invisible, which is why it bites in scripts, command-line tools and unit tests (use an expectation that lets the run loop spin, or avoid timers by injecting a scheduler).

The mode matters. .common includes the tracking mode that the main run loop switches to while the user is scrolling, so a timer added in .common keeps firing during scrolling, whereas .default pauses until scrolling ends (Q217). Use .common for UI-visible countdowns and clocks. autoconnect() starts the timer on subscribe and stops it when the last subscriber cancels; without it the publisher is connectable and does nothing until connect(). Remember that timers can fire late (the run loop is busy) and are coalesced when the app is in the background, so measure elapsed time from a clock (Date or ContinuousClock) instead of counting ticks, and cancel the timer when the screen disappears. For background periodic work prefer a dispatch timer source or an async sleep loop (Q530).

The timer does not fire until the run loop processes it, then it ticks three times.

Common traps

  • Counting ticks to measure time. Ticks are delayed or coalesced; compare timestamps.
  • Using .default mode for an on-screen countdown. It freezes while the user scrolls.

They might follow up with

  • Why autoconnect()? Timer.publish is a ConnectablePublisher; autoconnect connects on first subscription.
  • Does the timer retain what the sink captures? Yes, until cancelled, so avoid strong cycles with self.

Related questions

  • 11.9.3 What do debounce, throttle(latest: true) and throttle(latest: false) each emit for a burst of events, and…
  • 11.9.1 Which thread does each stage of a pipeline run on, and what do subscribe(on:) and receive(on:) each move?
  • 6.3.2 What are run loop modes, and why does a Timer stop firing while the user scrolls?
  • 6.3.1 What is a run loop, and what happens in one iteration?

← What do debounce, throttle(latest: true) and throttle(latest: false)…