Skillshub axiom-combine-patterns
Use when working with Combine publishers, AnyCancellable lifecycle, @Published properties, or bridging Combine with async/await. Covers reactive patterns, operator selection, memory management, and migration strategy.
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/CharlesWiltgen/Axiom/axiom-combine-patterns" ~/.claude/skills/comeonoliver-skillshub-axiom-combine-patterns && rm -rf "$T"
skills/CharlesWiltgen/Axiom/axiom-combine-patterns/SKILL.mdCombine Patterns
Overview
Combine remains embedded in massive production codebases — UIKit delegates, NotificationCenter bridging, KVO observation, and @Published properties are everywhere. New code prefers async/await, but interop and maintenance of existing Combine pipelines is daily work. This skill covers the decisions and pitfalls that matter: when to use Combine vs async/await, how to avoid memory leaks, and how to bridge between the two paradigms.
Core principle: Combine is not dead — it's mature. The question isn't "should I use Combine?" but "is Combine the right tool for THIS specific data flow?"
When to Use This Skill
- Working with existing Combine pipelines
- Deciding between Combine and async/await for a new data flow
- Debugging AnyCancellable memory leaks or silent pipeline failures
- Using @Published or ObservableObject
- Bridging Combine publishers with async/await code
- Working with Subjects (PassthroughSubject, CurrentValueSubject)
When NOT to Use This Skill
- Timer.publish patterns → route via
to timer-patterns skill (dedicated timer lifecycle coverage)axiom-ios-concurrency - @Observable migration from ObservableObject → use
(modern observation)axiom-swift-concurrency - UIKit ↔ SwiftUI bridging → route via
(view wrapping, not data flow)axiom-ios-ui - General async/await patterns → use
axiom-swift-concurrency
Example Prompts
- "Should I use Combine or async/await for this?"
- "My Combine pipeline silently stops producing values"
- "How do I convert a publisher to an async sequence?"
- "AnyCancellable is leaking — where do I store it?"
- "What's the difference between combineLatest and zip?"
- "How do I debounce a text field with Combine?"
- "My @Published property update isn't reaching the view"
- "How do I bridge a Combine publisher into async/await code?"
Part 1: Combine vs async/await Decision Tree
| Use Case | Combine | async/await | Why |
|---|---|---|---|
| One-shot network call | No | Yes | async/await is simpler, no cancellable management |
| Stream of values over time | Yes | AsyncStream | Combine's operators (debounce, combineLatest) are richer |
| Debounce/throttle user input | Yes | Awkward | Combine has built-in debounce/throttle; AsyncStream requires manual implementation |
| Merge multiple sources | Yes | TaskGroup | Combine's merge/combineLatest handle heterogeneous streams naturally |
| Existing UIKit KVO/Notification | Yes | Bridge | publisher(for:) and NotificationCenter.default.publisher are idiomatic Combine |
| New project iOS 17+ | No | Yes | @Observable + async/await is the modern pattern |
| Existing codebase with Combine | Maintain | Migrate incrementally | Don't rewrite working pipelines — bridge at boundaries |
Quick Decision
Is it a one-shot operation (network call, file read)? ├─ Yes → async/await (simpler, no cancellable management) │ Does it need time-based operators (debounce, throttle, delay)? ├─ Yes → Combine (built-in operators, no manual implementation) │ Are you combining multiple ongoing streams? ├─ Yes → Combine (combineLatest, merge, zip are purpose-built) │ Is this new code on iOS 17+? ├─ Yes → async/await + @Observable (modern pattern) │ Is it existing Combine code that works? └─ Yes → Keep it. Bridge at boundaries when async/await code needs the data.
Part 2: Publisher/Subscriber Lifecycle
AnyCancellable Storage Rules
AnyCancellable cancels its subscription when deallocated. If you don't store it, the pipeline is cancelled immediately after setup.
❌ Pipeline dies instantly
func setupPipeline() { publisher .sink { value in self.handle(value) // Never called } // AnyCancellable returned by sink is discarded → subscription cancelled }
✅ Store in Set<AnyCancellable>
private var cancellables = Set<AnyCancellable>() func setupPipeline() { publisher .sink { [weak self] value in self?.handle(value) } .store(in: &cancellables) }
Why Set, Not Array
Set<AnyCancellable> is the idiomatic choice because:
works with bothstore(in:)
andSet
(includingRangeReplaceableCollection
), butArray
is conventionalSet- Order doesn't matter for subscriptions
- Prevents accidental duplicates if setup runs twice
4 Memory Leak Patterns
Leak 1: Strong self in sink
// ❌ LEAK: sink closure captures self strongly publisher .sink { value in self.handle(value) // Strong capture → retain cycle } .store(in: &cancellables) // ✅ FIX: weak self publisher .sink { [weak self] value in self?.handle(value) } .store(in: &cancellables)
Leak 2: Missing store(in:)
// ❌ LEAK: cancellable assigned to local var, not stored let cancellable = publisher.sink { handle($0) } // cancellable deallocated at end of scope → pipeline cancelled // ✅ FIX: store in instance property publisher.sink { [weak self] in self?.handle($0) } .store(in: &cancellables)
Leak 3: Over-retained cancellables
// ❌ LEAK: cancellables set never cleared, old pipelines accumulate func refreshData() { // Each call adds another subscription without removing the previous one dataPublisher .sink { [weak self] in self?.update($0) } .store(in: &cancellables) } // ✅ FIX: clear before re-subscribing func refreshData() { cancellables.removeAll() // Cancel previous subscriptions dataPublisher .sink { [weak self] in self?.update($0) } .store(in: &cancellables) }
Leak 4: assign(to:on:) strong capture
assign(to:on:) captures the on: parameter strongly. When the target is self, you get a retain cycle: self → cancellables → subscription → self.
// ❌ LEAK: assign(to:on:) retains self strongly — deinit never called userPublisher .map { $0.name } .assign(to: \.displayName, on: self) .store(in: &cancellables) // ✅ FIX: use assign(to:) with @Published projected value (iOS 14+) userPublisher .map { $0.name } .assign(to: &$displayName) // No store(in:) needed — subscription tied to @Published property lifetime
Key difference:
assign(to: &$prop) does NOT return an AnyCancellable — the subscription is managed internally and cancelled when the @Published property's owner deallocates. No retain cycle, no cancellable storage needed.
If you must support iOS 13, use
sink with [weak self] instead.
Part 3: Essential Operators
One canonical example per group. These cover 90% of real-world usage.
Transform
// map: transform each value publisher.map { $0.name } // compactMap: transform + filter nil publisher.compactMap { Int($0) } // flatMap: one-to-many (each value produces a new publisher) searchText .flatMap { query in api.search(query) // Returns a publisher }
flatMap gotcha: Without
.switchToLatest() or maxPublishers: .max(1), flatMap creates a new inner publisher for every upstream value. For search-as-you-type, use map + switchToLatest instead:
searchText .map { query in api.search(query) } .switchToLatest() // Cancels previous search when new query arrives
Combine Multiple Sources
// combineLatest: latest value from each, fires when ANY changes Publishers.CombineLatest(namePublisher, agePublisher) .map { name, age in "\(name), \(age)" } // merge: interleave values from same-type publishers Publishers.Merge(localUpdates, remoteUpdates) .sink { update in handle(update) } // zip: pairs values 1:1 (waits for both to produce) Publishers.Zip(requestA, requestB) .sink { responseA, responseB in /* both complete */ }
| Operator | Fires When | Use Case |
|---|---|---|
| combineLatest | Any input changes | Form validation (all fields) |
| merge | Any input produces | Combining event streams |
| zip | All inputs produce one value | Parallel requests that must complete together |
Time-Based
// debounce: wait until values stop arriving (search-as-you-type) searchTextPublisher .debounce(for: .milliseconds(300), scheduler: RunLoop.main) .sink { [weak self] query in self?.search(query) } .store(in: &cancellables) // throttle: emit at most once per interval (scroll position) scrollOffsetPublisher .throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true) .sink { [weak self] offset in self?.updateHeader(offset) } .store(in: &cancellables)
| Operator | Behavior | Use Case |
|---|---|---|
| debounce | Waits for silence, then emits last value | Search fields, auto-save |
| throttle(latest: true) | Emits latest value at fixed intervals | Scroll tracking, sensor data |
| throttle(latest: false) | Emits first value at fixed intervals | Rate-limiting button taps |
Error Handling
// tryMap: transform that can throw publisher.tryMap { data in try JSONDecoder().decode(Model.self, from: data) } // mapError: convert error types publisher.mapError { error in AppError.network(error) } // replaceError: provide fallback value (terminates error path) publisher.replaceError(with: defaultValue) // retry: re-subscribe on failure publisher.retry(3) // Retry up to 3 times before propagating error
Error handling order matters:
retry should come before replaceError. Retry re-subscribes to the upstream publisher; replaceError terminates the error and makes the pipeline infallible.
api.fetchData() .retry(3) // Try 3 more times on failure .replaceError(with: cached) // If all retries fail, use cache .sink { data in update(data) } .store(in: &cancellables)
replaceError after flatMap kills the outer pipeline: If
replaceError is downstream of flatMap, a single inner publisher error terminates the entire pipeline — not just that one request. Move error handling inside flatMap so each inner publisher handles its own errors:
// ❌ One API error kills the entire pipeline $searchText .flatMap { query in api.search(query) } .replaceError(with: []) // Pipeline completes on first error .sink { ... } // ✅ Each search handles its own errors independently $searchText .flatMap { query in api.search(query) .replaceError(with: []) // Only this search affected } .sink { ... }
Part 4: @Published + ObservableObject
willSet Timing
@Published fires its publisher in willSet, not didSet. This means subscribers see the new value before the property has actually been set on the object.
class ViewModel: ObservableObject { @Published var count = 0 init() { $count.sink { newValue in // 'newValue' is the incoming value // BUT self.count is still the OLD value here print("New: \(newValue), Current: \(self.count)") // Prints "New: 1, Current: 0" when count is set to 1 } .store(in: &cancellables) } }
If you need to read the property's value after it's been set, don't subscribe to
$count — use a didSet observer instead, or read self.count after a brief deferral. The $ publisher is designed for reacting to the incoming value, not for reading post-mutation state.
Nested ObservableObject Trap
SwiftUI does NOT observe nested ObservableObject changes. Only the top-level object's
objectWillChange triggers view updates.
// ❌ View won't update when settings.theme changes class AppState: ObservableObject { @Published var settings = Settings() // Settings is also ObservableObject } class Settings: ObservableObject { @Published var theme = "light" // Changes here don't propagate } // ✅ FIX: Forward objectWillChange manually class AppState: ObservableObject { @Published var settings = Settings() private var cancellables = Set<AnyCancellable>() init() { settings.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) } }
Better fix for iOS 17+: Migrate to
@Observable, which handles nested observation automatically. See axiom-swift-concurrency for migration patterns.
Thread Safety Warning
@Published is NOT thread-safe. Setting a @Published property from a background thread triggers objectWillChange off the main thread, which can crash SwiftUI views.
// ❌ CRASH: @Published set from background thread class ViewModel: ObservableObject { @Published var data: [Item] = [] func fetch() { Task { let items = await api.fetchItems() data = items // Background thread → crash } } } // ✅ FIX: Ensure main thread @MainActor class ViewModel: ObservableObject { @Published var data: [Item] = [] func fetch() { Task { let items = await api.fetchItems() data = items // Safe — @MainActor ensures main thread } } }
Part 5: Bridging Combine and async/await
Publisher → AsyncSequence
Use
.values to consume any publisher as an async sequence:
let cancellable = notificationPublisher .sink { notification in handle(notification) } // ✅ Modern equivalent using .values for await notification in notificationPublisher.values { handle(notification) }
Caveats with
:.values
- The
loop runs indefinitely until the publisher completes or the Task is cancelledfor await - Errors thrown by the publisher terminate the loop
- Only one consumer — if two
loops consume the samefor await
, behavior is undefined.values
async/await → Publisher
Wrap an async function in
Future for Combine consumption:
func fetchUser(id: String) async throws -> User { ... } // Wrap as a Combine publisher let userPublisher = Future<User, Error> { promise in Task { do { let user = try await fetchUser(id: "123") promise(.success(user)) } catch { promise(.failure(error)) } } }
Future executes immediately — it runs its closure when created, not when subscribed. Wrap in
Deferred if you need lazy execution:
let lazyPublisher = Deferred { Future<User, Error> { promise in Task { do { let user = try await fetchUser(id: "123") promise(.success(user)) } catch { promise(.failure(error)) } } } }
Gradual Migration Strategy
Don't rewrite working Combine code. Bridge at the boundary:
Combine pipeline → .values → async/await code (bridge) async function → Future → Combine pipeline (bridge)
Migration priority:
- New code: write in async/await
- Boundary: bridge with
or.valuesFuture - Existing Combine: leave working pipelines alone
- Rewrite: only when the pipeline needs significant changes anyway
Part 6: Subjects
PassthroughSubject vs CurrentValueSubject
| Feature | PassthroughSubject | CurrentValueSubject |
|---|---|---|
| Initial value | None | Required |
| Late subscribers | Miss previous values | Get current value immediately |
property | No | Yes (read current value) |
| Use case | Events (button taps, notifications) | State (current selection, loading status) |
// Event-driven: no initial value, late subscribers miss past events let taps = PassthroughSubject<Void, Never>() taps.send() // State-driven: always has a current value let isLoading = CurrentValueSubject<Bool, Never>(false) isLoading.value = true // Direct access isLoading.send(false) // Also works
Send-After-Completion Pitfall
Once a Subject receives a completion event, all subsequent
send() calls are silently ignored. No crash, no error — just silence.
let subject = PassthroughSubject<Int, Never>() subject.send(1) // Delivered subject.send(completion: .finished) subject.send(2) // Silently ignored — no crash, no warning // This is the most common cause of "my pipeline stopped working"
Diagnosis: If a pipeline silently stops producing values, check whether anything upstream sent a
.finished or .failure completion. Once complete, the pipeline is dead.
Part 7: Cold vs Hot Publishers (share/multicast)
Most Combine publishers are cold — they start work when subscribed and each subscriber gets its own independent execution.
URLSession.dataTaskPublisher fires a new HTTP request per subscriber.
// ❌ Two subscribers = two network requests let publisher = URLSession.shared .dataTaskPublisher(for: url) .map(\.data) .eraseToAnyPublisher() publisher.sink { cache.store($0) }.store(in: &cancellables) // Request 1 publisher.sink { display($0) }.store(in: &cancellables) // Request 2
share()
.share() makes a cold publisher hot — the first subscriber triggers the work, subsequent subscribers share the output:
// ✅ One request, shared result let publisher = URLSession.shared .dataTaskPublisher(for: url) .map(\.data) .share() .eraseToAnyPublisher() publisher.sink { cache.store($0) }.store(in: &cancellables) // Triggers request publisher.sink { display($0) }.store(in: &cancellables) // Shares result
share() Gotchas
| Gotcha | Effect | Fix |
|---|---|---|
| Late subscribers miss values | uses PassthroughSubject — no replay | Attach all subscribers before the first value arrives, or use with |
| Upstream completed before subscriber attaches | Late subscriber immediately gets with no values | Ensure subscription order, or cache the result outside Combine |
| All subscribers cancel → upstream cancels | New subscriber after that triggers a NEW upstream execution | Expected behavior, but surprising if you assumed the result was cached |
When to use share()
Multiple subscribers to the same expensive publisher? ├─ No → Don't use share() (unnecessary complexity) │ ├─ Yes, all subscribe at the same time? │ └─ Yes → share() works │ └─ Yes, subscribers attach at different times? └─ Use multicast(subject:) with CurrentValueSubject, or cache the result in a property
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Combine is dead, just use async/await" | Combine has no deprecation notice. Thousands of production apps use it. Rewriting working pipelines wastes time and introduces bugs. Bridge incrementally instead. |
| "I'll just use .sink everywhere" | Without and proper , every sink is a potential memory leak. The lifecycle rules in Part 2 prevent the top 4 leak patterns. |
| "assign(to:on:) is fine, it's the standard API" | It captures strongly — retain cycle if target is . Use instead (Part 2, Leak 4). |
| "debounce and throttle are the same thing" | debounce waits for silence; throttle emits at intervals. Using the wrong one causes either delayed responses or missed events. Part 3 has the decision table. |
| "I know how @Published works" | @Published fires on willSet, not didSet. Nested ObservableObject doesn't propagate. Background thread access crashes. Part 4 covers all three traps. |
| "I'll migrate everything to async/await at once" | Full rewrites of working Combine code introduce bugs and waste time. Bridge at boundaries (Part 5). Rewrite only when the pipeline needs significant changes anyway. |
Pressure Scenarios
Scenario 1: "Let's migrate all Combine code to async/await"
Setup: Tech lead wants to modernize the codebase. "Combine is legacy, let's rip it out."
Pressure: Authority + scope creep. The entire data layer uses Combine publishers, @Published properties, and operator chains.
Expected with skill: Push back with the gradual migration strategy (Part 5). New code uses async/await. Boundaries use
.values and Future. Existing working pipelines stay until they need changes. Full rewrite is the most expensive option with the least benefit.
Pushback template: "Combine isn't deprecated — Apple still ships it in every SDK. A full rewrite of working pipelines introduces bugs we don't have today. Let's bridge at boundaries: new code in async/await,
.values to consume existing publishers, and we only rewrite a pipeline when we're already changing it significantly."
Scenario 2: "Pipeline silently stopped — just recreate it"
Setup: A Combine pipeline stopped producing values after a refactor. No crash, no error.
Pressure: Time pressure. "Just tear it down and rebuild."
Expected with skill: Diagnose before rebuilding. Check: (1) Was a completion sent upstream? (send-after-completion, Part 6). (2) Is the AnyCancellable still alive? (storage rules, Part 2). (3) Did the publisher error without handling? (replaceError / catch, Part 3). These three causes cover 90% of silent pipeline failures.
Diagnostic checklist:
- Is the
still stored? (Set not cleared, not deallocated)AnyCancellable - Did anything upstream send
or.finished
?.failure - Is there a
or other throwing operator without error handling?tryMap - Was
used where the outer publisher completed?switchToLatest
Pushback template: "Before rebuilding, let me check four things: cancellable lifecycle, upstream completions, unhandled errors, and switchToLatest completion. One of these is almost always the cause. It takes 5 minutes to diagnose vs 30 minutes to rebuild and test."
Scenario 3: "Settings changes aren't updating the UI"
Setup: A settings screen uses a nested ObservableObject. The parent
AppState holds a Settings object. When the user changes settings.theme, the UI doesn't update.
Pressure: "The binding works in isolation, it must be a SwiftUI bug. Let me just force a refresh with objectWillChange.send()."
Expected with skill: Recognize the nested ObservableObject trap (Part 4). SwiftUI does NOT observe nested ObservableObject changes — only the top-level object's
objectWillChange triggers view updates. The fix is either forwarding objectWillChange from the nested object, or migrating to @Observable (iOS 17+) which handles nesting automatically.
Anti-pattern without skill: Sprinkling
objectWillChange.send() calls throughout the code, adding @Published to every nested property (which doesn't help), or restructuring the model to flatten everything into one object (losing separation of concerns).
Pushback template: "SwiftUI only observes the top-level ObservableObject. Nested objects need their objectWillChange forwarded to the parent. Part 4 has the exact pattern — it's a 5-line fix in the parent's init, not a SwiftUI bug."
Resources
WWDC: 2019-722, 2019-721, 2020-10034
Docs: /combine, /combine/anycancellable, /combine/published
Skills: swift-concurrency, memory-debugging