Mastodon AsyncCombine: Because Async Code Shouldn’t Be Ugly | My Portfolio

AsyncCombine: Because Async Code Shouldn’t Be Ugly

Will Lumley | Oct 1, 2025 min read

When Swift first introduced Combine, I loved it. The syntax was expressive, pipelines were easy to follow, and @Published made state changes feel almost magical. But as Apple pushed us toward Swift Concurrency, I decided to bite the bullet and migrate one of my apps away from Combine.

The migration worked; async/await is powerful, predictable, and in many ways simpler. But I quickly noticed something, parts of my code just weren’t as easy to read anymore. A neat Combine one-liner became a few clunky async calls. And to make things worse, I couldn’t rely on @Published to observe changes between view models anymore.

Here’s a concrete example. Here’s a view model you might see before the @Observable macro was implemented.

import Combine

class CounterViewModel: ObservableObject {
    @Published public var count = 0

    init(count: Int) {
        self.count = count
    }
}

The @Published property wrapper makes it easy to bind properties to SwiftUI views - and equally easy to observe those properties from other view models. The @Published property wrapper essentially turns a property into a lightweight publisher; every time the value changes, anyone subscribed is instantly notified.

This makes it trivial to wire up relationships like:

import Combine

class LoggerViewModel {
    private var cancellables = Set<AnyCancellable>()

    init(counterViewModel: CounterViewModel) {
        counterViewModel.$count
            .sink { value in
                print("Count Updated:", value)
            }
            .store(in: &cancellables)
    }
}

Or if you wanted, you could map the published value and assign it to another view model’s property, like so:

import Combine

class LoggerViewModel {
    private var cancellables = Set<AnyCancellable>()
    private var countDescription = ""

    init(counterViewModel: CounterViewModel) {
        counterViewModel.$count
            .removeDuplicates()
            .map { "Count: \($0)" }
            .assign(on: \.countDescription, to: self)
            .store(in: &cancellables)
    }
}

And just like that, you have LoggerViewModel’s countDescription automatically listen in to any CounterViewModel’s count property updates, map the value, and either log it or assign the transformed value to itself.

Amazing, right?

Introducing Swift Concurrency

With the release of swift-async-algorithms and the fact that Combine hasn’t really seen a meaningful update since 2020, it’s pretty clear where Apple wants us to head: Swift Concurrency is the future.

And honestly, that’s a good thing. Async/await gives us structured concurrency, language-level cancellation, and AsyncSequence as a powerful primitive for streams. It’s predictable, modern, and way less error-prone than callback hell or juggling Combine operators that sometimes felt… magical in ways you didn’t always want.

To add onto this, Combine is a library that exists only in the Apple ecosystem. This means you can’t use it in Swift programs that target Windows/Linux, and you can’t use it in anything server-side. Compare this to Swift Concurrency, which exists as a set of first-class citizens baked into the Swift language itself, and you can start to see the appeal.

Now, to be fair, if all you need is ViewModel to SwiftUI View data updates, Swift Concurrency already has a great story. With the new @Observable macro, property changes in your models can automatically drive SwiftUI updates, and it feels every bit as smooth as @Published did.

But where things get messy is ViewModel to ViewModel data updates. That’s where you start to feel the loss of Combine. There’s no built-in equivalent of subscribing to another object’s property updates. There’s no $count publisher you can just sink or assign. You’re left rolling your own AsyncStream wrappers or juggling manual task loops, and the ergonomics take a big hit.

So yes, Swift Concurrency is the future… but moving from Combine isn’t always painless.

Let’s go back to our CounterViewModel. With Combine, we saw how easy it was to sink or assign its @Published values. With Swift Concurrency alone, that turns into something like this:

import AsyncAlgorithms
import Observation

@Observable
class CounterViewModel {
    public var count = 0

    init(count: Int) {
        self.count = count
    }
}

class LoggerViewModel {
    private var task: Task<Void, Never>?
    @MainActor private(set) var countDescription = ""

    init(counterViewModel: CounterViewModel) {
        let countChanges = Observations {
            counterViewModel.count
        }

        self.task = Task { [weak self] in
            for await text in countChanges
                .removeDuplicates()
                .map({ "Count: \($0)" })
            {
                await MainActor.run {
                    self?.countDescription = text
                }
            }
        }

    }
}

It works. It’s correct. It’s platform-agnostic. But it’s not elegant.

Suddenly you’re manually managing tasks, dealing with for await loops, and if you want to transform or map those values, you’re either nesting more loops or pulling in swift-async-algorithms. And while async algorithms are powerful, the resulting code doesn’t read as cleanly as a Combine pipeline.

The end result? Readability takes a hit. That neat, declarative one-liner you had in Combine now feels heavier and harder to scan at a glance.

And that’s before you start talking about missing operators like CombineLatest, or primitives like CurrentValueSubject.

This is where I started thinking: what if I could bring back Combine’s expressive syntax; but reimagine it for Swift Concurrency?

Introducing AsyncCombine

That frustration is what led me to build AsyncCombine. The idea was simple: keep the async/await foundations that Apple clearly wants us to use, but bring back the clean, chainable operators that made Combine such a joy to write.

AsyncCombine gives you familiar tools like sink, assign, and store(in:), rebuilt on top of AsyncSequence and Swift’s new Observation framework. That means you get the readability of Combine, but without relying on Combine itself. And because it’s just Swift Concurrency under the hood, it works on iOS, macOS, and even Linux.

Here’s our CounterViewModel and LoggerViewModel example again, but written with AsyncCombine:

import AsyncCombine

@Observable
class CounterViewModel {
    public var count = 0
}

class LoggerViewModel {
    private var tasks = Set<SubscriptionTask>()

    init(counterViewModel: CounterViewModel) {
        counterViewModel.observed(\.count)
            .sink { value in
                print("Count Updated:", value)
            }
            .store(in: &tasks)
    }
}

Notice how this looks and feels almost exactly like the Combine version? No manual Task, no for await loop; just a declarative pipeline that you can scan at a glance.

And it’s not just sink. AsyncCombine builds on Swift Concurrency and works seamlessly with swift-async-algorithms, giving you operators like map, filter, merge, throttle, and removeDuplicates out of the box.

counterViewModel.observed(\.count)
    .removeDuplicates()
    .map { "Count: \($0)" }
    .assign(on: \.countDescription, to: self)
    .store(in: &tasks)

and, in my biased opinion, is much more elegant than:

self.task = Task { [weak self] in
    for await text in countChanges
        .removeDuplicates()
        .map({ "Count: \($0)" })
    {
        await MainActor.run {
            self?.countDescription = text
        }
    }
}

Feels like Combine again, but async/await-native. 🚀

Rebuilding the Missing Pieces

AsyncCombine isn’t just about nicer syntax. Once I started leaning on it in real apps, I realized there were some fundamental building blocks from Combine that I really missed. Two in particular: CurrentValueSubject and Publishers.CombineLatest(_, _:).

CurrentValueSubject

In Combine, CurrentValueSubject was my go-to whenever I needed a stream that always had a “latest value.” Subscribers would immediately get the current value, then receive future updates. Simple, powerful, and incredibly handy for bridging state between layers.

Swift Concurrency doesn’t have an equivalent; so I built CurrentValueRelay. It’s a replay-1 async primitive: every subscriber instantly sees the most recent value, then stays up-to-date as new values come in.

let relay = CurrentValueRelay(0)

relay.sink { value in
    print("Subscriber A:", value)
}
.store(in: &tasks)

// Update the value
await relay.set(1)

// New subscriber instantly replays latest
relay.sink { value in
    print("Subscriber B:", value)
}
.store(in: &tasks)

The output of the above would be:

Subscriber A: 0
Subscriber A: 1
Subscriber B: 1

This makes CurrentValueRelay perfect for keeping view models or services in sync, without writing extra boilerplate.

Publishers.CombineLatest

The other operator I couldn’t live without was Publishers.CombineLatest. It made it so easy to merge two (or more) streams into one, always keeping the latest values in play.

So AsyncCombine includes a CombineLatest operator for AsyncSequence.

let streamA = AsyncChannel<Int>()
let streamB = AsyncChannel<String>()

streamA.combineLatest(streamB)
    .sink { int, string in
        print("Combined:", int, string)
    }
    .store(in: &tasks)

Task {
    await streamA.send(1)
    await streamB.send("hello")
    await streamA.send(2)
    await streamB.send("world")
}

The output of the above would be:

Combined: 1 hello
Combined: 2 hello
Combined: 2 world

Just like in Combine, whenever either upstream produces a value, you get a fresh tuple with the latest from both sides.

Together, CurrentValueRelay and AsyncCombine.CombineLatest bring back two of Combine’s most powerful patterns — now in an async/await-native form.

Why It Matters

At this point you might be thinking: “Okay, this is just syntax sugar, right?”

But in practice, AsyncCombine solves a very real problem: keeping async code readable and expressive.

Swift Concurrency gives us the strong foundations - structured tasks, cancellation, AsyncSequence - but when your app grows beyond a few isolated Task { … } blocks, the lack of ergonomic operators starts to show.

Here’s what AsyncCombine brings back to the table: • Readability — A one-liner .sink { ... } is easier to scan than a for await loop buried inside a task. • Composition — Operators like map, filter, and CombineLatest let you describe intent directly, without hand-wiring streams together. • State bridging — CurrentValueRelay makes it trivial to share “latest state” across layers without bolting on extra machinery. • Cross-view model observation — observed(_:) gives you back the simplicity of listening to another view model’s property updates. • Cross-platform support — Built purely on Swift Concurrency, AsyncCombine works on iOS, macOS, and even Linux/server-side Swift.

AsyncCombine doesn’t try to replace Swift Concurrency, it complements it. You still get all the benefits of async/await being a first-class part of the language, but with the expressive, declarative style that made Combine so enjoyable to use.

And because it’s not tied to Apple frameworks, AsyncCombine is lightweight, portable, and future-proof.

You can check out the source code and integrate AsyncCombine into your projects now.

👉 Check it out on GitHub