Reactive frameworks vs async/await vs AsyncAlgorithms


Greetings, traveler!

There was a time when reactive programming felt like the natural answer to many iOS architecture problems.

Before Swift Concurrency, asynchronous programming on Apple platforms was spread across many different mechanisms. A network request usually came with a completion handler. User interaction came through target-action, delegates, or closures. System events often arrived through NotificationCenter. Older APIs exposed changes through KVO. Background work lived somewhere between GCD, OperationQueue, custom queues, and carefully placed calls back to the main thread.

Each of these tools worked, but each of them had its own shape.

A delegate callback looked different from a notification. A completion handler looked different from KVO. A timer looked different from a text field event. A network response looked different from a reachability update. When an application grew, the codebase often became a mix of small asynchronous islands, each using a slightly different communication style.

Reactive frameworks gave developers one language for all of that. With RxSwift, everything could become an Observable. With Combine, everything could become a Publisher. Once data was represented as a stream, the same set of tools could be used everywhere: map, flatMap, combineLatest, debounce, throttle, switchLatest, retry, share. That was powerful.

A text field change, a button tap, a network response, a cached value, a timer, and a notification could all be described through the same mental model: values over time. Instead of wiring callbacks manually, developers could describe how values moved through the system.

For example, a search screen suddenly had a clean shape. The user types into a field, the query is debounced, duplicate values are ignored, the previous request is cancelled when a new query arrives, and the latest result updates the UI. Reactive code expressed that flow directly.

The same applied to forms, validation, loading states, notification badges, realtime updates, payment calculations, and many other screens where several independent inputs affected one final state.

Frameworks like RxSwift and ReactiveSwift gave iOS developers a common language for asynchronous and event-driven code. Combine later brought a similar idea into Apple’s own ecosystem, with publishers, subscribers, operators, and cancellation built around AnyCancellable.

Reactive programming became popular because it solved a real problem. Apple platforms had many asynchronous and event-driven APIs, but they lacked a unified model for composing them. RxSwift, ReactiveSwift and Combine offered that model, and for many years, that made them feel like the missing piece in iOS architecture.

Where reactive still fits well

Reactive programming still has a place in modern iOS development. The important part is to use it for the kind of problems where its model actually matches the shape of the work.

The best candidates are flows built from many independent inputs, where values keep changing over time and the application needs to maintain a derived state from those changes. This is where reactive code can express the behavior more directly than a chain of manually coordinated tasks.

A good reactive use case usually has several of these characteristics at the same time: many input sources, continuous updates, derived state, debounce or throttle, latest-only requests, shared streams, replay of the latest value, multiple subscribers, and tests that benefit from virtual time.

A payment quote screen is a good example. Imagine a screen where the user enters an amount, selects an account, chooses a recipient, changes the currency, enters a promo code, and the app also observes network status and feature flags. Every one of these values can change independently. Any change can affect the final quote.

The amount changes when the user types. The account changes when the user picks another funding source. The recipient can change the transfer rules. The selected currency can affect conversion. The promo code can change the total. Network status can make the quote unavailable. Feature flags can enable or disable parts of the flow.

From all of that, the screen needs to build one coherent state. The app should validate the input, debounce quote requests, ignore or cancel old requests when newer input arrives, show a loading state while the quote is being calculated, turn errors into screen state, and keep the flow alive after a failed request. Several parts of the UI may need the same latest result: the fee label, the total label, the submit button, a warning banner, and maybe analytics.

This is where reactive code starts to feel suitable. The problem is no longer a single asynchronous operation. It is a graph of changing values. A reactive pipeline can describe that graph directly: combine the latest inputs, validate them, debounce changes, start the latest quote request, cancel stale work, map the result into state, and share that state downstream.

For this kind of feature, reactive frameworks can still be a strong fit. Operators like combineLatest, debounce, switchToLatest, catch, retry, share, and replay-style primitives are not accidental conveniences. They describe real behavior that appears in production apps.

The key point is that reactive code is a good fit when the complexity is already reactive in nature. When the product flow is built around values changing over time, and those values need to be composed into a consistent derived state, a reactive framework can make the code easier to reason about.

The cost of reactive code

Reactive programming can make complex flows look elegant, but that elegance comes with a price. Sometimes it becomes larger than the problem itself.

Learning curve

Reactive code requires a separate mental model. A developer needs to understand hot and cold streams, subscription lifetime, DisposeBag or AnyCancellable, scheduler semantics, share, replay, error termination, backpressure, and the order in which operators are applied.

These concepts are learnable, and experienced reactive developers can use them very effectively. The problem is that this knowledge is not optional once a codebase relies on reactive pipelines. A developer cannot safely change a complex chain by only understanding the business logic. They also need to understand the stream semantics.

Even senior iOS developers can read reactive code more slowly than regular async/await code, especially when a pipeline mixes timing, cancellation, sharing, error handling, and thread switching in one place.

Hidden control flow

A reactive pipeline can look compact, but the actual execution path is often less obvious than it appears. When does the work start? How many times does the chain subscribe? What happens when an error is emitted? Does the underlying request actually get cancelled? Is the result shared between subscribers, or does each subscriber trigger the work again? Which scheduler is used at each point?

These questions matter in production code. A missing share, an incorrectly placed catch, or a scheduler change in the wrong part of the chain can completely change the behavior. The code may still compile, and the pipeline may still look reasonable, while the runtime behavior becomes subtly wrong.

Memory management

Reactive code makes it easy to create long-lived subscriptions, and long-lived subscriptions make ownership mistakes more expensive.

A strong self inside sink or subscribe, a forgotten cancellable, a shared stream that lives longer than expected, a subject retaining state, or a pipeline hidden inside a service can keep objects alive in ways that are hard to notice during code review. These bugs often appear as screens that never deallocate, duplicate events, repeated network requests, or old state being delivered after the original owner should have disappeared.

Boilerplate

In Combine, even a fairly simple chain can accumulate eraseToAnyPublisher(), receive(on:), sink, and store(in: &cancellables). In RxSwift, the same role is often played by DisposeBag, Driver, Signal, observeOn, subscribeOn, asObservable, and asSingle.

This boilerplate is not always bad. Some of it makes ownership, scheduling, and API boundaries explicit. But it still adds weight. Every extra concept becomes something the team has to understand, review, test, and maintain.

Debugging

With async/await, the code often reads as a sequence of steps. You can place a breakpoint and follow the flow. With reactive code, the behavior may be split across operators, subscriptions, schedulers, and shared streams. The actual source of a duplicated request or missing value can be several operators away from the place where the symptom appears.

A strategic cost

RxSwift and ReactiveSwift are third-party dependencies. They are mature and widely used, but they still put a core part of the application architecture outside Apple’s native platform direction. The team depends on an external ecosystem for maintenance, migration paths, compatibility, and long-term evolution.

Combine has a different problem. It is first-party, which removes the dependency risk, but it has not evolved with the same momentum as Swift Concurrency. Apple still documents Combine as a declarative framework for processing values over time, and the async bridge through Publisher.values makes interoperability possible. Still, most of the visible direction in modern Swift has moved toward async/await, structured concurrency, actors, AsyncSequence, and Observation.

This matters when choosing a default for new code. A framework can be stable and useful while no longer being the center of gravity for the platform.

A team cost

If a project depends heavily on a reactive framework, hiring and onboarding become more specific. New developers need to learn the framework, the project’s conventions, and the subtle rules around ownership and scheduling before they can confidently work on core flows. Code review also becomes harder, because reviewers need enough reactive experience to spot problems that are not visible from the business logic alone.

This is why reactive code should earn its place. A compact pipeline is valuable only when the team can maintain it safely. If the same feature can be expressed clearly with async/await, explicit state, and native Swift tools, the simpler solution often has a better long-term cost profile.

Thread safety is not just scheduling

Thread safety is one of the areas where comparisons between reactive frameworks and Swift Concurrency often become blurry.

Reactive frameworks give us powerful tools for controlling execution context. In RxSwift, we have subscribe(on:), observe(on:), Scheduler, SerialDispatchQueueScheduler, and MainScheduler. In Combine, we have operators like subscribe(on:) and receive(on:). These tools are useful because they allow us to decide where work starts, where values are delivered, and where UI updates should happen.

That matters a lot in real applications. A network request should not block the main thread. A heavy transformation may need to run on a background queue. A UI update must happen on the main thread. Reactive frameworks make this kind of thread hopping explicit and composable.

But scheduling and state isolation are different problems. A scheduler can control where work happens. An actor can express who owns mutable state.

This distinction is important. With RxSwift or Combine, we can serialize access by convention. We can decide that a stream should emit values on a serial scheduler. We can make sure that a subject is accessed from one queue. We can document that a piece of state should only be touched from a specific pipeline. With enough discipline, this can work well.

Swift Concurrency approaches the problem from another angle. Actors, global actors, MainActor, and Sendable allow the language to express isolation rules directly. The compiler can then warn when code crosses isolation boundaries incorrectly or when non-safe data is passed across concurrency domains.

That is a very different level of support. A Subject, BehaviorRelay, or CurrentValueSubject is not an actor. These types can be useful building blocks for state propagation, but they do not give the same compile-time guarantees around isolated mutable state. They can emit values. They can hold or replay values. They can be used behind a carefully designed API. But they do not make the compiler understand who owns the state and who is allowed to mutate it.

This is where the argument “we already had this in Rx” becomes too broad. Reactive frameworks already had tools for composing asynchronous events. They already had tools for moving work between execution contexts. They already had ways to serialize streams and avoid some classes of race conditions through disciplined design.

But Swift Concurrency adds a native language model for isolation. It gives us a way to express that a piece of mutable state belongs to an actor, that UI state belongs to the MainActor, and that data crossing concurrency boundaries should be safe to transfer.

For example, an image cache, token store, session manager, feature flag store, or in-flight request registry may all contain shared mutable state. In a reactive codebase, we can protect that state through private queues, subjects, locks, or serial schedulers. In Swift Concurrency, an actor can make the ownership model part of the type itself.

That does not mean actors magically solve every concurrency problem. They introduce their own trade-offs, including reentrancy, async access, and the need to design isolation boundaries carefully. But they shift part of the burden from developer discipline to the language and compiler.

This is the key difference. Reactive frameworks help us describe how values move through time. Swift Concurrency helps us describe how asynchronous work and mutable state are isolated.

Why Swift Concurrency changed the default

Swift Concurrency changed the default because it gave Swift its own native model for asynchronous work.

The goal was larger than making completion handlers prettier. async/await was the most visible part of the change, but it arrived together with a much broader set of tools: structured concurrency, task cancellation, actors, MainActor, Sendable, and AsyncSequence.

This direction did not appear out of nowhere. Swift had a long-running discussion around concurrency, including the Swift Concurrency Manifesto, where many of the underlying problems were already visible: callback-heavy APIs, unclear ownership of asynchronous work, shared mutable state, and the difficulty of making concurrent code safe without turning every codebase into a collection of locks, queues, and conventions.

Before Swift Concurrency, many common patterns were possible, but they were easy to get wrong. Completion handlers could create nested flows that were hard to read and harder to cancel. GCD made it possible to move work between queues, but the compiler could not tell whether the chosen queue was correct. UI updates had to be manually dispatched to the main thread. Shared state needed locks, serial queues, or strict team discipline. Cancellation was often custom, inconsistent, or missing entirely.

Swift Concurrency gave these problems language-level concepts. An asynchronous function can now describe its behavior directly in the function signature:

func loadUser() async throws -> User

The caller can read the code in a natural order:

let user = try await userService.loadUser()
let settings = try await settingsService.loadSettings()

Structured concurrency gives child tasks a relationship to their parent task. Cancellation becomes part of the task hierarchy instead of a random flag passed through several layers. async let and task groups allow parallel work without losing the shape of the operation.

Actors address another part of the problem. They let us isolate mutable state behind a concurrency boundary:

actor TokenStore {
    private var token: String?

    func update(_ token: String) {
        self.token = token
    }

    func currentToken() -> String? {
        token
    }
}

Now the isolation is visible in the type system. Access to actor-isolated state must respect the actor boundary. This does not remove the need for design, but it gives the compiler a chance to help.

MainActor brings the same idea to UI work. Instead of scattering DispatchQueue.main.async throughout the codebase, we can mark the type or method that owns UI state:

@MainActor
final class ProfileViewModel {
    private(set) var state: State = .idle

    func load() async {
        state = .loading
        // ...
    }
}

Sendable adds another piece to the puzzle by describing values that can safely cross concurrency boundaries. AsyncSequence extends the model from one asynchronous result to values that arrive over time, which makes it possible to represent streams without leaving the Swift Concurrency world.

For a simple network request, a save operation, a login flow, or a sequence of dependent asynchronous steps, async/await is usually easier to read than a reactive pipeline. For shared mutable state, actors offer a native isolation model. For UI state, MainActor and SwiftUI Observation fit naturally into modern Swift code. For streams, AsyncSequence gives Swift a standard protocol for asynchronous values over time.

Swift Concurrency is not just a replacement for completion handlers. It is Swift’s native model for asynchronous work and isolation. That makes it the natural starting point for new code, even though reactive frameworks can still be useful in more specialized cases.

The Xcode prompt: “Avoid Combine…”

There is also a small but telling signal in modern tooling. Xcode’s AI guidance suggests avoiding Combine when an async/await version of an API is available.

This does not mean Combine is deprecated, but it does reflect the direction of modern Swift code. When Apple provides an async API, the expected default is usually to call it with await, handle errors with do/try/catch, isolate UI state with MainActor, and use task cancellation when the operation is no longer needed.

For example, a simple request-response flow does not need a publisher pipeline anymore:

@MainActor
func loadProfile() async {
    state = .loading

    do {
        let profile = try await profileService.loadProfile()
        state = .loaded(profile)
    } catch {
        state = .failed(error)
    }
}

This kind of code is direct, native, and easy to follow. There is no subscription lifetime to manage, no cancellable to store, no scheduler to choose, and no type erasure around the public API.

That is the real point of the prompt. Combine is no longer the natural default for every asynchronous operation. The platform has moved toward Swift Concurrency, and the tooling reflects that.

Is reactive obsolete?

However, reactive programming is not obsolete. There are still problems where Combine, RxSwift, or ReactiveSwift can be a good fit. Complex stream composition is one of them. If a feature is built from many independent event sources, and those events need to be combined, transformed, throttled, retried, shared, and replayed, reactive code can describe the flow very well.

Legacy codebases are another practical case. If an application already has a mature RxSwift architecture, good internal conventions, reliable testing helpers, and a team that understands the framework deeply, rewriting everything only to follow the newest platform direction may bring more risk than value.

UIKit-heavy apps can also still benefit from reactive bindings, especially when the project has already standardized around them. UIKit is naturally event-driven, and many of its APIs are based on delegates, target-action, notifications, and callbacks. Reactive frameworks can provide a consistent layer over those patterns.

There are also specific technical cases where reactive frameworks remain strong: shared event streams, debounced workflows, latest-only requests, multiple subscribers, replay of the latest value, and virtual-time testing. These are not imaginary problems. They exist in production apps, and reactive frameworks have mature answers for them.

A search screen is a simple example. A payment quote screen is a stronger one. A realtime dashboard, sync engine, websocket-driven feature, collaborative editor, or complex form engine may also fit this model. In these cases, the feature behaves less like a sequence of operations and more like a graph of changing values.

But most new code does not start there. Most new code starts with an operation:

let profile = try await profileService.loadProfile()

Or a command:

try await paymentService.submitPayment(request)

Or isolated state:

actor SessionStore {
    private var session: Session?

    func update(_ session: Session?) {
        self.session = session
    }
}

Or UI state:

@Observable
@MainActor
final class ProfileViewModel {
    private(set) var state: State = .idle
}

For those cases, the modern Swift default is different.

Use async/await for operations. Use actors for isolated mutable state. Use Observation and SwiftUI for UI state. Use AsyncSequence when values arrive over time. Use AsyncAlgorithms when those asynchronous sequences need operators like debounce, throttle, merge, or combineLatest.

It changes the burden of proof. A few years ago, adding RxSwift or Combine often felt like a reasonable architectural default because the platform had no single native story for asynchronous work. Today, Swift has one. That means reactive code should be introduced because the problem genuinely benefits from stream composition, sharing, replay, or virtual-time testing, not simply because something is asynchronous.

Reactive can still be the right tool for a stream-heavy feature or a mature codebase that already uses it well. But for most new Swift code, the starting point has moved. The default is now Swift Concurrency, with reactive frameworks reserved for the cases where their extra power clearly pays for their extra cost.

AsyncAlgorithms as the missing piece

Swift Concurrency gives us a strong default for asynchronous work, but plain async/await has an obvious limitation: it is mostly shaped around operations that start, suspend, and eventually return. Many UI and application flows are shaped differently. They produce values over time.

AsyncSequence gives Swift Concurrency a standard way to represent asynchronous streams. A download can produce progress updates. A notification source can produce events. A search field can produce changing queries. A websocket can produce messages. Instead of subscribing with sink, we can iterate with for await.

AsyncAlgorithms builds on top of that model. It adds many of the operators that made Combine and RxSwift useful in the first place: debounce, throttle, merge, zip, combineLatest, and channel-like primitives such as AsyncChannel. In that sense, it narrows the gap between Swift Concurrency and reactive frameworks.

For example, a debounced search can be written without creating a publisher pipeline:

@MainActor
func observeSearchQueries() async {
    for await query in queryStream
        .debounce(for: .milliseconds(300))
        .removeDuplicates()
    {
        await search(query)
    }
}

The idea is familiar if you have used Combine or RxSwift. Values arrive over time, repeated values can be skipped, input can be debounced, and the result can be handled in a loop that still feels like regular Swift.

The same applies to combining streams. Suppose a screen needs to display a notification badge from two independent sources: unread messages and pending friend requests. In Combine, combineLatest would be the natural tool. With AsyncAlgorithms, the shape can stay very similar:

@MainActor
func observeBadgeCount() async {
    for await (messages, requests) in combineLatest(
        unreadMessagesStream,
        friendRequestsStream
    ) {
        badgeCount = messages.count + requests.count
    }
}

AsyncAlgorithms allows code to stay inside the Swift Concurrency world while still solving problems that used to push developers toward a reactive framework.

The migration story can be gradual as well. For instance, you may want start migrating your Combine-based code to AsyncAlgorithms. Existing publishers can be exposed as asynchronous sequences through values, so Combine can move behind repository boundaries while newer code consumes async streams. Just keep Combine subjects private, expose async sequences, then consume those values with for await and AsyncAlgorithms operators.

Time-based streams are another good fit. A download callback may emit progress updates far more often than the UI needs. Combine would usually use throttle. AsyncAlgorithms can express the same intent through an async sequence:

@MainActor
func observeDownloadProgress() async {
    for await progress in downloadProgressStream
        .throttle(for: .milliseconds(50), latest: true)
    {
        displayedProgress = progress
    }
}

The code still has a stream, an operator, and a consumer. The difference is that the consumer is a normal async loop, and UI isolation can be expressed through @MainActor instead of receive(on:).

This makes AsyncAlgorithms very useful for local stream processing. It is a good fit when there is one clear consumer, a small number of streams, and a need for familiar operators like debounce, throttle, merge, zip, or combineLatest.

At the same time, it is important to avoid overselling it as a new RxSwift. AsyncAlgorithms gives Swift Concurrency a set of stream operators. It does not automatically give us the full reactive infrastructure that mature RxSwift or Combine codebases often rely on.

The biggest gaps show up around sharing and replaying streams. A CurrentValueSubject or BehaviorRelay can hold the latest value and immediately deliver it to a new subscriber. That behavior is very useful for state-like data: current user, current session, feature flags, selected account, cached profile, or the latest payment quote. AsyncChannel has a different shape. It is useful for sending values through an asynchronous sequence, but it behaves more like a channel of events than a state container with current-value replay.

By juxtaposing CurrentValueSubject and AsyncChannel, we can see that the channel can transmit values, but it lacks the same “latest value is immediately accessible to a new subscriber” functionality.

Broadcasting is another important difference. In reactive frameworks, a shared stream with multiple subscribers is a normal pattern. A single source can feed a label, a button state, an analytics event, and another derived stream. Operators like share, replay-style utilities, multicast patterns, and traits such as Driver in RxSwift exist because shared delivery is a common need.

With AsyncSequence, that story is less mature. A sequence is usually consumed through an iterator, and multiple independent consumers are not always a safe or natural assumption. AsyncSequence is not designed as a general broadcasting and multiplexing mechanism in the same way reactive developers might expect. However, this may change in the future.

There is also the testing angle. RxSwift has a mature virtual-time testing ecosystem. Complex debounce, throttle, delay, retry, and timeout flows can be tested with a TestScheduler. Swift Concurrency has clocks and more native tools over time, but the experience is still different from the long-established reactive testing model.

AsyncAlgorithms helps Swift Concurrency handle streams. It makes many Combine-style transformations available without leaving native async code. It can make migration easier, especially when the public API can move from publishers to async sequences. For many features, that is enough.

But if a system depends heavily on shared streams, replay, multicast behavior, driver-like UI guarantees, complex reactive graphs, or virtual-time testing, Combine, ReactiveSwift, or RxSwift can still provide a more complete model.

The practical conclusion is simple: reach for AsyncAlgorithms when the problem is stream-shaped, but still local enough to fit Swift Concurrency naturally. Use it for debounced input, throttled progress, merged events, combined async sequences, and channel-based communication. Once the feature starts to require shared replayed state with many subscribers, AsyncAlgorithms alone may push you toward building your own reactive layer. At that point, keeping Combine, ReactiveSwift, or RxSwift in that part of the system can be the more honest choice.

Code example: one problem, three solutions

To make the comparison fair, let’s avoid a simple network request. A single request is a poor example for Combine, because async/await is clearly the better fit there. If all we need is to call an API, receive one value, and handle one error, a publisher pipeline usually adds more structure than the problem needs.

Instead, let’s use a case where reactive programming usually a good fit: a payment quote screen.

This example is available on my GitHub as well.

The screen has several inputs that can change independently:

var amountText = ""
var selectedAccount: Account?
var selectedRecipient: Recipient?
var selectedCurrency: Currency = .nzd
var promoCodeText = ""
var networkStatus: NetworkStatus = .online
var featureFlags = FeatureFlags(
    promoCodesEnabled: true,
    internationalTransfersEnabled: true
)

Every input affects the same result. The app needs to build a valid PaymentDraft, debounce quote requests, cancel stale work, show loading, convert errors into state, and keep the latest ready quote for submit.

The validation itself is shared between all three implementations:

enum PaymentDraftBuilder {
    static func build(
        amountText: String,
        account: Account?,
        recipient: Recipient?,
        currency: Currency,
        promoCodeText: String,
        networkStatus: NetworkStatus,
        featureFlags: FeatureFlags
    ) -> Result<PaymentDraft, PaymentValidationError> {
        guard networkStatus == .online else {
            return .failure(.offline)
        }

        guard let amount = Decimal(string: amountText), amount > 0 else {
            return .failure(.emptyAmount)
        }

        guard let account else {
            return .failure(.missingAccount)
        }

        guard account.balance >= amount else {
            return .failure(.insufficientFunds)
        }

        guard let recipient else {
            return .failure(.missingRecipient)
        }

        let isInternational = recipient.countryCode != "NZ"
        guard !isInternational || featureFlags.internationalTransfersEnabled else {
            return .failure(.internationalTransfersDisabled)
        }

        let promoCode: PromoCode?
        if featureFlags.promoCodesEnabled, !promoCodeText.isEmpty {
            promoCode = PromoCode(value: promoCodeText)
        } else {
            promoCode = nil
        }

        return .success(
            PaymentDraft(
                amount: amount,
                account: account,
                recipient: recipient,
                currency: currency,
                promoCode: promoCode
            )
        )
    }
}

The interesting part is not the validation. The interesting part is how each approach reacts when any input changes.

Combine version

In the Combine implementation, each input is represented as a CurrentValueSubject.

@ObservationIgnored private let amountTextSubject = CurrentValueSubject<String, Never>("")
@ObservationIgnored private let selectedAccountSubject = CurrentValueSubject<Account?, Never>(nil)
@ObservationIgnored private let selectedRecipientSubject = CurrentValueSubject<Recipient?, Never>(nil)
@ObservationIgnored private let selectedCurrencySubject = CurrentValueSubject<Currency, Never>(.nzd)
@ObservationIgnored private let promoCodeTextSubject = CurrentValueSubject<String, Never>("")
@ObservationIgnored private let networkStatusSubject = CurrentValueSubject<NetworkStatus, Never>(.online)
@ObservationIgnored private let featureFlagsSubject = CurrentValueSubject<FeatureFlags, Never>(
    FeatureFlags(
        promoCodesEnabled: true,
        internationalTransfersEnabled: true
    )
)

The observable properties feed those subjects from didSet:

var amountText = "" {
    didSet { amountTextSubject.send(amountText) }
}

var selectedAccount: Account? {
    didSet { selectedAccountSubject.send(selectedAccount) }
}

var selectedRecipient: Recipient? {
    didSet { selectedRecipientSubject.send(selectedRecipient) }
}

var selectedCurrency: Currency = .nzd {
    didSet { selectedCurrencySubject.send(selectedCurrency) }
}

This is already a small compromise in a modern SwiftUI app. The UI state is handled by Observation, while Combine needs its own subjects to build a pipeline. But once the streams exist, the flow becomes very expressive.

let draft = Publishers.CombineLatest4(
    amountTextSubject.removeDuplicates(),
    selectedAccountSubject.removeDuplicates(),
    selectedRecipientSubject.removeDuplicates(),
    selectedCurrencySubject.removeDuplicates()
)
.combineLatest(
    promoCodeTextSubject.removeDuplicates(),
    networkStatusSubject.removeDuplicates(),
    featureFlagsSubject.removeDuplicates()
)
.map { firstGroup, promoCodeText, networkStatus, featureFlags in
    let (amountText, account, recipient, currency) = firstGroup

    return PaymentDraftBuilder.build(
        amountText: amountText,
        account: account,
        recipient: recipient,
        currency: currency,
        promoCodeText: promoCodeText,
        networkStatus: networkStatus,
        featureFlags: featureFlags
    )
}
.eraseToAnyPublisher()

This part is where Combine fits very naturally. The code says: take the latest values from all inputs and build a draft whenever anything changes.

Then the quote pipeline adds timing, loading, error handling, and latest-only behavior:

draft
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .map { [quoteAPI] result -> AnyPublisher<PaymentState, Never> in
        switch result {
        case let .failure(error):
            return Just(PaymentState.invalid(error))
                .eraseToAnyPublisher()

        case let .success(draft):
            return Deferred {
                let subject = PassthroughSubject<PaymentQuote, Error>()

                let task = Task {
                    do {
                        let quote = try await quoteAPI.loadQuote(for: draft)
                        subject.send(quote)
                        subject.send(completion: .finished)
                    } catch is CancellationError {
                        subject.send(completion: .finished)
                    } catch {
                        subject.send(completion: .failure(error))
                    }
                }

                return subject
                    .handleEvents(receiveCancel: {
                        task.cancel()
                    })
                    .eraseToAnyPublisher()
            }
            .map { quote in
                PaymentState.ready(draft, quote)
            }
            .catch { error in
                Just(PaymentState.failed(draft, error.localizedDescription))
            }
            .prepend(.loading(draft))
            .eraseToAnyPublisher()
        }
    }
    .switchToLatest()
    .receive(on: RunLoop.main)
    .sink { [weak self] state in
        self?.state = state

        if case let .ready(draft, quote) = state {
            self?.latestReadyDraftAndQuote = (draft, quote)
        } else {
            self?.latestReadyDraftAndQuote = nil
        }
    }
    .store(in: &cancellables)

This pipeline is dense, but it expresses several important rules in one place.

debounce prevents the app from requesting a new quote on every keystroke. prepend(.loading) emits loading before the request completes. catch turns request errors into screen state, so the whole flow does not die after one failed quote. switchToLatest makes sure the newest draft wins and stale quote requests cannot update the UI.

This is a strong Combine use case. The feature behaves like a graph of changing values, and Combine describes that graph directly.

Async/await version

The async/await implementation solves the same problem with explicit state and manual task orchestration.

Each input calls scheduleRecalculation() from didSet:

var amountText = "" {
    didSet { scheduleRecalculation() }
}

var selectedAccount: Account? {
    didSet { scheduleRecalculation() }
}

var selectedRecipient: Recipient? {
    didSet { scheduleRecalculation() }
}

var selectedCurrency: Currency = .nzd {
    didSet { scheduleRecalculation() }
}

var promoCodeText = "" {
    didSet { scheduleRecalculation() }
}

var networkStatus: NetworkStatus = .online {
    didSet { scheduleRecalculation() }
}

var featureFlags = FeatureFlags(
    promoCodesEnabled: true,
    internationalTransfersEnabled: true
) {
    didSet { scheduleRecalculation() }
}

The debounce and cancellation logic is written manually:

@ObservationIgnored private var quoteTask: Task<Void, Never>?
@ObservationIgnored private var latestReadyDraftAndQuote: (PaymentDraft, PaymentQuote)?

private func scheduleRecalculation() {
    quoteTask?.cancel()

    quoteTask = Task { [weak self] in
        do {
            try await Task.sleep(for: .milliseconds(300))
            try Task.checkCancellation()
            await self?.recalculateQuote()
        } catch is CancellationError {
            return
        } catch {
            return
        }
    }
}

Then the quote recalculation reads the current properties, validates them, updates state, starts the request, checks cancellation, and writes the final state:

private func recalculateQuote() async {
    let result = PaymentDraftBuilder.build(
        amountText: amountText,
        account: selectedAccount,
        recipient: selectedRecipient,
        currency: selectedCurrency,
        promoCodeText: promoCodeText,
        networkStatus: networkStatus,
        featureFlags: featureFlags
    )

    switch result {
    case let .failure(error):
        latestReadyDraftAndQuote = nil
        state = .invalid(error)

    case let .success(draft):
        latestReadyDraftAndQuote = nil
        state = .loading(draft)

        do {
            let quote = try await quoteAPI.loadQuote(for: draft)
            try Task.checkCancellation()

            latestReadyDraftAndQuote = (draft, quote)
            state = .ready(draft, quote)
        } catch is CancellationError {
            return
        } catch {
            latestReadyDraftAndQuote = nil
            state = .failed(draft, error.localizedDescription)
        }
    }
}

This version is easier to debug step by step. There is no publisher chain, no type erasure, no AnyCancellable, and no scheduler semantics. A developer can put breakpoints in scheduleRecalculation() and recalculateQuote() and follow the flow.

The cost is that the reactive behavior is now implemented manually.

The code manually debounces with Task.sleep. It manually cancels the previous quote task. It manually checks cancellation after the API call. It manually clears and updates the latest ready quote. It manually ensures that errors become state.

For one screen, this can be completely acceptable. In many teams, this version may be easier to maintain than the Combine pipeline because it uses plain Swift control flow. But if the feature grows, this style can slowly become a hand-written reactive system.

AsyncAlgorithms version

The AsyncAlgorithms version sits between these two approaches.

It does not model every input as a typed stream. Instead, every input change sends a simple event into an AsyncChannel.

@ObservationIgnored private let inputEvents = AsyncChannel<Void>()

@ObservationIgnored private var workerTask: Task<Void, Never>?
@ObservationIgnored private var quoteTask: Task<Void, Never>?
@ObservationIgnored private var latestReadyDraftAndQuote: (PaymentDraft, PaymentQuote)?

Each input still triggers recalculation through didSet:

var amountText = "" {
    didSet { enqueueRecalculation() }
}

var selectedAccount: Account? {
    didSet { enqueueRecalculation() }
}

var selectedRecipient: Recipient? {
    didSet { enqueueRecalculation() }
}

var selectedCurrency: Currency = .nzd {
    didSet { enqueueRecalculation() }
}

The difference is that debouncing is no longer implemented through Task.sleep inside every recalculation task. It is expressed as an async sequence operation:

private func enqueueRecalculation() {
    quoteTask?.cancel()

    Task {
        await inputEvents.send(())
    }
}

private func startObservingInputs() {
    workerTask = Task { [weak self, inputEvents] in
        for await _ in inputEvents.debounce(for: .milliseconds(300)) {
            self?.refreshQuote()
        }
    }
}

This is cleaner than the manual async/await debounce. The code now says: take input events, debounce them, then refresh the quote.

The quote request itself still uses manual task cancellation:

private func refreshQuote() {
    let result = PaymentDraftBuilder.build(
        amountText: amountText,
        account: selectedAccount,
        recipient: selectedRecipient,
        currency: selectedCurrency,
        promoCodeText: promoCodeText,
        networkStatus: networkStatus,
        featureFlags: featureFlags
    )

    switch result {
    case let .failure(error):
        latestReadyDraftAndQuote = nil
        state = .invalid(error)

    case let .success(draft):
        latestReadyDraftAndQuote = nil
        state = .loading(draft)

        quoteTask = Task { [weak self, quoteAPI] in
            do {
                let quote = try await quoteAPI.loadQuote(for: draft)
                try Task.checkCancellation()

                self?.setReadyState(draft: draft, quote: quote)
            } catch is CancellationError {
                return
            } catch {
                self?.setFailedState(draft: draft, message: error.localizedDescription)
            }
        }
    }
}

This version shows the value of AsyncAlgorithms well. It removes part of the manual timing logic and keeps the code inside the Swift Concurrency world. AsyncChannel gives us a stream of input events, and debounce expresses the timing rule directly.

But it also shows the limitation. The stream carries Void, not a typed tuple of latest input values. The current values are still read manually from mutable properties. The latest-only quote request is still implemented with quoteTask?.cancel(). The code does not have a direct equivalent of the Combine pipeline where combineLatest, debounce, map, switchToLatest, catch, and sink describe one continuous data flow.

AsyncAlgorithms helps, but it does not fully recreate Combine’s graph composition.

What the three versions show

The Combine version expresses the data flow most directly. It treats the feature as a graph of changing values. Inputs are streams, validation is a transformation, debounce is an operator, quote loading is an inner publisher, and switchToLatest captures the rule that only the latest request should update the screen.

The async/await version is explicit and easy to follow. It uses regular Swift control flow, manual state, and manual task cancellation. This can be a better fit for many teams, especially when the feature is not stream-heavy enough to justify a reactive framework.

The AsyncAlgorithms version is a useful middle ground. It removes some low-level timing code and brings stream operators into Swift Concurrency. It works well for local event streams, but in this example, it still needs manual state snapshots and manual cancellation for the quote task.

That is the trade-off in one example.

Combine gives the most expressive reactive graph.
async/await gives the most direct imperative flow.
AsyncAlgorithms narrows the gap, especially around time-based stream processing, while leaving some reactive infrastructure unsolved.

The team cost matters more than the pipeline

The Combine version in the previous example is technically elegant. It describes the payment quote flow as a graph of values. Once the inputs become publishers, the rest of the code has a very natural shape: combine the latest values, debounce changes, start a quote request, cancel stale work, map the result into state, and keep the screen updated.

That is a strong argument for Combine. But architecture decisions are rarely about one pipeline in isolation. They are about the team that will live with the code for years.

A good question is not only whether the Combine version is shorter or more expressive. A better question is whether every developer on the team can debug it safely.

Can every developer understand when the pipeline starts? Can every reviewer notice if a request is duplicated because sharing was placed in the wrong part of the chain? Can they catch a subtle cancellation bug around switchToLatest? Can they reason about what happens when an inner publisher fails? Can they tell whether a value is replayed, shared, or recomputed for every subscriber?

These are not theoretical concerns. They are the kind of details that decide whether reactive code remains elegant or turns into a fragile part of the codebase.

Hiring and onboarding also matter.

If a project depends on advanced Combine or RxSwift patterns, a new developer needs to learn more than the business domain. They need to learn the reactive model, the framework’s operators, the project’s conventions, and the team’s rules around lifetime, scheduling, sharing, and error handling. Until that knowledge is in place, code review becomes harder and changes become riskier.

Most iOS developers today are expected to understand async/await, Task, MainActor, and structured concurrency. These tools are part of the language direction, part of Apple’s documentation, and part of modern Swift APIs. A new team member still needs to understand the project architecture, but they do not need to learn a separate reactive framework before they can follow a simple data load or submit flow.

Consistency has value too. A codebase where one feature uses Combine pipelines, another uses RxSwift, another uses async/await, and another uses AsyncSequence can become difficult to navigate even when every local decision was reasonable. Each abstraction brings its own vocabulary and lifecycle rules. The cost appears during maintenance, refactoring, debugging, and review.

That is why the best technical solution is not always the best team solution. The async/await version may look more manual than the Combine version. It may repeat some patterns that Combine expresses more compactly. But it uses ordinary control flow, explicit state, and native cancellation. For many teams, that trade-off is worth it because the code is easier to read, easier to step through, and easier to hand over to someone who has not spent years writing reactive pipelines.

It means Combine/RXSwift needs a stronger reason. If the feature is truly stream-heavy, if the team already has deep reactive experience, if virtual-time testing is important, or if shared replayed streams are central to the design, reactive code can pay for itself. But if the benefit is only that one pipeline looks elegant, the team cost may be too high.

A useful rule is to ask this before introducing reactive code into new features:

Can the team debug this under pressure?

If the answer is uncertain, native Swift tools are often the safer default.

The real migration strategy

The practical migration strategy is not to rewrite everything. If an existing Combine or RxSwift flow works, is covered by tests, and the team understands it well, there is rarely much value in replacing it only for the sake of using newer syntax. A rewrite can introduce bugs into code that was already stable, especially when the original flow contains subtle behavior around sharing, replay, retry, cancellation, or scheduling.

A better strategy is to change the default for new code. When a new feature needs a simple asynchronous operation, start with async/await:

func loadProfile() async throws -> Profile {
    try await profileAPI.loadProfile()
}

When a feature owns mutable state that can be accessed from multiple tasks, consider an actor:

actor SessionStore {
    private var session: Session?

    func update(_ session: Session?) {
        self.session = session
    }

    func currentSession() -> Session? {
        session
    }
}

When UI state is involved, keep it close to SwiftUI (except performance-sensitive modules) and Observation:

@MainActor
@Observable
final class ProfileViewModel {
    private(set) var state: State = .idle

    func load() async {
        state = .loading

        do {
            let profile = try await profileService.loadProfile()
            state = .loaded(profile)
        } catch {
            state = .failed(error)
        }
    }
}

The important part is to avoid introducing reactive code into new flows where native Swift already gives a clear solution. A single request, a submit action, a load screen, or a simple sequence of dependent operations usually does not need a publisher or observable.

For existing reactive code, migration can happen at the boundaries. If a repository currently uses Combine internally, it does not always need to expose Combine publicly. The internal implementation can keep a subject, while the public API moves toward async/await or AsyncSequence.

For one-shot operations, expose an async function:

protocol UserRepository {
    func loadUser() async throws -> User
}

For streams, expose an asynchronous sequence:

protocol SessionRepository {
    var sessionUpdates: AsyncStream<SessionState> { get }
}

This keeps the feature layer aligned with Swift Concurrency while allowing older implementation details to retire gradually. Combine or RxSwift can live behind repositories, adapters, or service boundaries. At least for a while.

This approach also helps with third-party SDKs. If a dependency still exposes publishers, observables, delegates, or callbacks, the rest of the app does not need to inherit that shape. An adapter can translate the SDK into the model used by the feature:

final class ReachabilityAdapter {
    var updates: AsyncStream<NetworkStatus> {
        AsyncStream { continuation in
            let token = reachability.observe { status in
                continuation.yield(status)
            }

            continuation.onTermination = { _ in
                token.cancel()
            }
        }
    }
}

New streams can use AsyncSequence when the shape fits naturally. If a feature has one clear consumer and needs a small amount of stream processing, AsyncAlgorithms can be enough:

@MainActor
func observeSearch() async {
    for await query in searchQueries
        .debounce(for: .milliseconds(300))
        .removeDuplicates()
    {
        await loadResults(for: query)
    }
}

But the migration should stay honest. If a part of the system depends on shared replayed streams, many subscribers, complex virtual-time tests, or a mature RxSwift pipeline that the team already trusts, keeping the reactive implementation may be the better engineering decision.

A realistic migration plan looks like this:

Keep working Combine and RxSwift code where it already pays for itself. Avoid adding reactive frameworks to new simple async flows. Prefer async/await for public one-shot APIs. Prefer AsyncSequence for new streams when there is a natural stream boundary. Hide legacy reactive code behind repositories and adapters. Move feature code toward Swift Concurrency gradually, without turning migration into a risky rewrite.

The goal is to make native Swift the default surface area for new development, while letting older reactive code remain where it still provides value.

Conclusion

Reactive frameworks gave developers a unified way to model asynchronous events before Swift had a native concurrency story. In many UIKit codebases, RxSwift brought order to delegates, callbacks, notifications, timers, text input, network responses, and state changes. Combine later brought a similar model into Apple’s own ecosystem.

But the platform has changed. Swift Concurrency, actors, MainActor, Sendable, Observation, AsyncSequence, and AsyncAlgorithms now cover most everyday asynchronous work with lower conceptual overhead and better integration with the language. A network request can be an async throws function. UI state can live on the MainActor. Shared mutable state can be isolated inside an actor. Streams can be represented with AsyncSequence. Local stream transformations can use AsyncAlgorithms.

This changes the default choice. Reactive frameworks are still useful, especially in systems where stream composition is central to the product. A complex realtime feature, a mature Rx-heavy codebase, a UIKit application with established bindings, a shared event stream with replay, or a workflow that relies heavily on virtual-time testing can still justify reactive code.

But for most applications and most teams, the cost is harder to justify today. Reactive code brings power, but it also brings a separate mental model, a steeper learning curve, subtle lifetime rules, hidden control flow, scheduler semantics, and additional review complexity. Those costs matter more when the platform already provides native tools that solve the common cases well.

Reactive code should no longer be introduced only because something is asynchronous. Asynchronous work is normal Swift now. A task that starts, awaits a result, and updates state does not need a publisher or observable by default.

If a feature truly behaves like a graph of changing values, reactive programming can still be the cleanest way to express it. If the problem requires shared streams, replay, latest-only requests, complex composition, or mature reactive testing, reactive frameworks may still be the right tool.

But if the problem can be clearly expressed with async/await, actors, Observation, AsyncSequence, or AsyncAlgorithms, native Swift is usually the better starting point.

The question is no longer whether reactive can solve the problem. It usually can. The better question is whether the problem is complex enough to justify paying for reactive as an architectural dependency.