I’m a bit conflicted when it comes to my opinion and preferences towards using RxSwift vs Async/Await for async operations. I rewrote this article multiple times trying to refine my thinking, hopefully this one captures it, so hear me out.
Over the years I’ve been exploring and experimenting with different async solutions on iOS. Things like Threads, Grand Central Dispatch (GCD), NSOperations and NSOperationQueues, Futures, Promises, etc. Ultimately, I came to the conclusion that the best way to solve asynchronicity on iOS is by using ReactiveX streams and observables to model it.
But more recently, with the introduction of Swift 5.5 in 2021, Swift Concurrency promised to solve the asynchronicity problem by providing async/await, actors, and other features.
By the time it came out, I was all in on Rx, but also learned that it’s not a silver bullet for every problem and that it shouldn’t be used everywhere in every layer of your application. To achieve the best results for the effort you put in and to reduce the number of potential errors you should NOT use Rx Observables and Streams in the UI layer of your application. Everywhere else it’s fair game - business logic, networking layer, service layer, etc.
So effectively, the asynchronicity problem was solved for me. Rx has a steep learning curve but if you apply it and use it with discipline you won’t have any problems.
And then comes async/await that’s claimed to be the best at asynchronicity. Well, I examined it more thoroughly as I conducted my research on the Async chapter of The iOS Interview Guide 2.0 and it has its pros and cons, but still Rx would solve all of these problems even before for me. This is how it stacks up:
async functions to model asynchronous operations - already had that with Single and Observable in Rx
Sequential execution with await and parallel execution of async operations with async let - yep, already had that with higher order functions such as flatMap, concat, zip, combineLatest, merge, etc.
State management with actor - yea, had that, with Subject, BehaviorRelay, etc. There are more advanced compile time controls and safety that comes with Swift Concurrency though, more on that later.
Error handling with do/try/catch/async throw - we had that with onError callback, catchError, retry, etc. That said, error handling might be a bit easier in some scenarios with Swift Concurrency vs RxSwift
Operation cancellation and cleanup with Task/isCancelled - RxSwift has a more robust clean way of doing it with subscribe, Disposable, and DisposeBag. More on that later.
UI data binding with ObservableObject - Rx has it via Driver but as I mentioned above this is a mute point for me since I prefer not to have any data bindings and reactivity in my UI layer.
Completely absent feature in Swift Concurrency - Scheduler. It’s a powerful feature of RxSwfit that allows you to control the execution context of observables with the async operation block’s or its callback. More on that below.
As you can already see from this short list - almost everything Swift Concurrency offers RxSwift provides plus more. Let’s take a look at those exceptions or specific things that need clarification.
State Management with Actor vs Subject/BehaviorReplay
Overall, there is nothing that you can do with Actor that you can’t do with RxSwift’s Subject using observeOn, subscribeOn, and serialize. But there is one clear advantage that Swift Concurrency has here - built-in compiler time checks for data isolation. Swift Concurrency also has Sendable and other tools for data isolation and thread safety.
In reality though, for over 15 years of me building iOS applications, I have never ever encountered a situation where data access over different threads was a problem. Usually, if you’d get to that point and start having issues with thread data access, race conditions, etc., it’s a symptom of a larger architectural problem rather than an “async problem to solve”.
Ask yourself - why do you have threading data access issues in the first place? Is there an improperly architected singleton in your app that everyone is trying to access and read/write data from/to? Most likely there is. You shouldn’t be using singletons in the first place, they are an antipattern. Just purge them and re-architect your data access and this will solve your problem, not an actor bandaid from Apple.
RxSwift wins here in my books because it gives you way more flexibility but I have to admit - it loses on the compile time checks, it’s better to have a compiler help you than not.
Error Handling in Swift Concurrency vs RxSwift
Here it might be closer to a tie. On one hand, RxSwift’s errors are thrown for the entire chain/stream in just one callback block at the tail end of the stream when you call it which makes it hard to debug. On the other hand, RxSwift has a lot of different catch and retry methods that could help you make the error behavior more sophisticated.
Swift Concurrency though has a more straightforward approach by using try/do/catch blocks. That’s a win for Swift Concurrency here.
Operation Cancellation and Cleanup
Swift Concurrency is just weird if not outright lazy here. After you call cancel() on a Task, which is similar to Disposable’s cancel() method, you are then supposed to constantly check Task.isCancelled or try Task.checkCancellation() to make sure your async operation doesn’t perform wasteful work after it was cancelled.
Ummmm… do what now? What is this primitive stateful procedure programming? Are we back in college, hacking together a god-user-object where all the state lives and we need to flip all sorts of states and flags on it to achieve everything in our codebase and ultimately drown in a sea of state related bugs?
This is just lazy and unprofessional on Apple’s part - this is not how modern software should be built. Most developers will forget to check the flag and won’t clean up resources causing subtle bugs and wasteful performance.
This is where RxSwift wins over Apple’s stuff hands down - every Observable has a dispose callback block that will be executed whenever the async operation is cancelled. This is where you would do proper cleanup of resources scoped to just your observable specifically. Nice, efficient, clean and tidy.
RxSwift’s Scheduler
This is another area where Rx wins hands down. The level of control and sophistication of it using Scheduler is untouched by Swift Concurrency - it doesn’t even have such a concept.
With the Scheduler, you can schedule operations to perform on the main thread, background thread, and schedule the call back when it emits values to perform on various threads as well. Even more, you can model the time itself in tests by using TestScheduler which allows you to run synchronous unit-tests that mock time ticks mimicking asynchronicity. Hands down a winner there!
Yes yes, I know what you’d say - “but Alex, you can use @MainActor and the await MainActor.run and other things like that!”. Yes, you can… but as I said above, they have nowhere near the sophistication you can achieve with the Scheduler.
Conclusion
At this point, I’m not ready to completely switch to Swift Concurrency and use async/await everywhere in my code, because RxSwift just provides so much more power and sophistication to solve async tasks. But at the same time, async/await has its merits too, for simpler linear operations such as network requests, for example. Plus, it has an easier learning curve than RxSwift.
RxSwift requires you to exercise discipline because it quickly can get out of control, but it gives you a lot of power. With Swift Concurrency you can be a bit more lax.
At the end of the day I use both, luckily RxSwift has several niceties and extensions to interoperate with Swift Concurrency and map async operations or streams to observables and singles and vice versa.
P.S. If you liked this content please share it, it helps spread the word about this substack! Also, I’m working on the 2nd edition of The iOS Interview Guide, topics like this will be covered in the Async Work chapter of the book. Subscribe if you want to hear more updates about my book’s progress.
Those are great points Alex.
However I would still lean towards Swift concurrency, even if it’s less powerful and more lazy.
The reason is as you mentioned compiler integration. I’m not necessarily talking about data races checks, but global help of the compiler writing asynchronous code.
Also, writing asynchronous code with Swift concurrency feels like writing the synchronous code we all already know.
The overhead, or even paradigm shift with Rx, is much less pronounced.
It’s not a silver bullet but I’d default to Swift concurrency, especially for beginners.
This is genuinely one of the worst articles I’ve read, not just recently, but ever. I don’t even know where to begin explaining why, because it’s just that baffling. In the comparison section, you’re treating RxSwift like it's the built-in, official tool, while portraying async as some random third-party solution.
And then there's this quote:
"In reality though, for over 15 years of me building iOS applications, I have never ever encountered a situation where data access over different threads was a problem. Usually, if you’d get to that point and start having issues with thread data access, race conditions, etc., it’s a symptom of a larger architectural problem rather than an 'async problem to solve'."
I honestly don’t even know how to respond to that. You’ve never had shared state that needed to be updated from multiple places? Never? What kind of miraculous architecture are you using that just completely sidesteps all of that?
And then you say:
“Just purge them and re-architect your data access and this will solve your problem, not an actor bandaid from Apple.”
Calling actors a bandaid? I don’t even want to comment on that.