npx claudepluginhub charleswiltgen/axiom --plugin axiomThis skill uses the workspace's default tool permissions.
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 t...
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
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?"
axiom-ios-concurrency to timer-patterns skill (dedicated timer lifecycle coverage)axiom-swift-concurrency (modern observation)axiom-ios-ui (view wrapping, not data flow)axiom-swift-concurrency| 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 |
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.
AnyCancellable cancels its subscription when deallocated. If you don't store it, the pipeline is cancelled immediately after setup.
func setupPipeline() {
publisher
.sink { value in
self.handle(value) // Never called
}
// AnyCancellable returned by sink is discarded → subscription cancelled
}
private var cancellables = Set<AnyCancellable>()
func setupPipeline() {
publisher
.sink { [weak self] value in
self?.handle(value)
}
.store(in: &cancellables)
}
Set<AnyCancellable> is the idiomatic choice because:
store(in:) works with both Set and RangeReplaceableCollection (including Array), but Set is conventional// ❌ 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: 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: 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)
}
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.
One canonical example per group. These cover 90% of real-world usage.
// 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
// 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 |
// 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 |
// 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 { ... }
@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.
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.
@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
}
}
}
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:
for await loop runs indefinitely until the publisher completes or the Task is cancelledfor await loops consume the same .values, behavior is undefinedWrap 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))
}
}
}
}
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:
.values or Future| Feature | PassthroughSubject | CurrentValueSubject |
|---|---|---|
| Initial value | None | Required |
| Late subscribers | Miss previous values | Get current value immediately |
.value 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
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.
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() 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
| Gotcha | Effect | Fix |
|---|---|---|
| Late subscribers miss values | share() uses PassthroughSubject — no replay | Attach all subscribers before the first value arrives, or use multicast with CurrentValueSubject |
| Upstream completed before subscriber attaches | Late subscriber immediately gets .finished 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 |
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
| 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 [weak self] and proper store(in:), 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 on: strongly — retain cycle if target is self. Use assign(to: &$prop) 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. |
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."
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:
AnyCancellable still stored? (Set not cleared, not deallocated).finished or .failure?tryMap or other throwing operator without error handling?switchToLatest used where the outer publisher completed?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."
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."
WWDC: 2019-722, 2019-721, 2020-10034
Docs: /combine, /combine/anycancellable, /combine/published
Skills: swift-concurrency, memory-debugging