The aim of this post is to get introduced to Combine by comparing it with regular operations that we perform on a daily basis while programming in Swift. It focuses on illustrating how common imperative patterns can be re-expressed using a reactive and declarative approach.
From Callback to Combine
Using a traditional completion handler is straightforward and lightweight. It works well for single, isolated asynchronous operations where you just need to return a Result once and move on. It introduces no dependency on Combine and keeps the control flow explicit and easy to follow. However, composition becomes cumbersome as soon as you need to chain multiple async steps, handle retries, debounce input, combine multiple data sources, or coordinate cancellation. Cancellation and flow control must be designed manually, and complex logic can quickly devolve into nested closures or scattered error handling.
struct User: Decodable {
let id: Int
let name: String
}
final class API {
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) {
completion(.success(User(id: id, name: "Ada")))
}
}
} Wrapping the same API in a Combine Future turns the operation into a Publisher, which enables declarative composition with operators like map, flatMap, retry, and catch. This makes it much easier to build scalable, testable pipelines and maintain architectural consistency (e.g., in MVVM). The trade-offs are additional conceptual complexity, lifecycle management via AnyCancellable, and the fact that Future is eager and single-shot. Moreover, cancellation of a subscription does not automatically cancel the underlying work unless the original API supports it and you explicitly propagate that behavior.
import Combine
final class APICombine {
private let api = API()
func fetchUser(id: Int) -> AnyPublisher<User, Error> {
Future { [api] promise in
api.fetchUser(id: id) { result in
promise(result)
}
}
.eraseToAnyPublisher()
}
}
var cancellables = Set<AnyCancellable>()
func fetchUser(id: Int) {
let api = APICombine()
api.fetchUser(id: 1)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
print("Error:", error)
}
},
receiveValue: { user in
print("User:", user.name)
}
)
.store(in: &cancellables)
} Real Networking: URLSession.dataTaskPublisher
Using URLSession.dataTaskPublisher provides a highly declarative and composable approach to networking. It integrates natively into the Combine pipeline, allowing you to chain operators such as map, tryMap, decode, retry, catch, and receive(on:) in a single, expressive stream. This makes transformation, error propagation, threading, and cancellation first-class concerns. It also improves testability when paired with dependency injection and custom URLSession configurations, and it aligns naturally with reactive UI layers (e.g., SwiftUI with @Published). The main advantages are composability, consistency with reactive architecture, built-in cancellation via AnyCancellable, and clearer data-flow semantics.
import Foundation
import Combine
struct Post: Decodable {
let id: Int
let title: String
}
enum APIError: Error {
case badStatus(Int)
}
final class PostsService {
func fetchPosts() -> AnyPublisher<[Post], Error> {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap { output -> Data in
if let http = output.response as? HTTPURLResponse,
!(200...299).contains(http.statusCode) {
throw APIError.badStatus(http.statusCode)
}
return output.data
}
.decode(type: [Post].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
} However, dataTaskPublisher introduces additional abstraction and complexity compared to a traditional completion-handler approach. The generic types in Combine pipelines can become difficult to read, often requiring eraseToAnyPublisher() to manage API surface complexity. Debugging reactive chains can also be more challenging due to operator layering and asynchronous propagation. Moreover, for simple one-off requests, a completion handler or even Swift’s modern async/await syntax may be more readable and straightforward. Combine shines in scenarios involving multiple asynchronous streams, transformation pipelines, or UI bindings, but it may feel unnecessarily heavy for basic networking tasks.
let service = PostsService()
func PostServiceUsage() {
service.fetchPosts()
.receive(on: DispatchQueue.main) // UI updates
.sink(
receiveCompletion: { print("Completion:", $0) },
receiveValue: { posts in print("Posts:", posts.count) }
)
.store(in: &cancellables)
} NotificationCenter in Combine
Using NotificationCenter with the classic addObserver API is straightforward and has minimal abstraction overhead. It works well for simple, fire-and-forget events and does not require knowledge of reactive programming. However, it is loosely typed (the notification’s payload is typically extracted from userInfo), which increases the risk of runtime errors. It also requires manual lifecycle management: you must ensure observers are removed appropriately (unless relying on the block-based API introduced in iOS 9+), and there is no built-in way to declaratively transform, filter, or compose events. As the number of notifications grows, code can become fragmented and harder to reason about.
import Foundation
import UIKit
func classicUsage() {
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
print("App activa")
}
} The Combine-based NotificationCenter.Publisher approach integrates notifications into a reactive stream, enabling strong composition through operators like map, filter, debounce, and merge. This allows you to declaratively transform and coordinate notification-driven events within a unified asynchronous pipeline. Memory management is also more explicit and predictable via AnyCancellable, and cancellation semantics are clearer. The trade-offs are increased conceptual complexity and a steeper learning curve, particularly for teams unfamiliar with reactive paradigms. Additionally, for very simple use cases, Combine may introduce unnecessary abstraction compared to the traditional observer pattern.
func combineUsage() {
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { _ in
print("App active")
}
.store(in: &cancellables)
}
Timer as a Publlisher
Using Timer.publish(every:on:in:) from Combine provides a declarative, composable approach to time-based events. Instead of imperatively scheduling a Timer and manually invalidating it, you model time as a stream of values that can be transformed with operators like map, scan, throttle, or debounce. This makes it especially powerful when the timer is just one part of a larger reactive pipeline (e.g., polling a network endpoint, driving UI state, or coordinating multiple asynchronous streams). Memory management is also more uniform: cancellation is handled via AnyCancellable, which integrates naturally with other Combine subscriptions. The main advantage is composability and consistency within a reactive architecture.
import Combine
func setupTimer() {
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.scan(0) { count, _ in count + 1 } // contador
.sink { value in
print("Segundos:", value)
}
.store(in: &cancellables)
} In contrast, the traditional Timer.scheduledTimer approach is simpler and often more readable for isolated, imperative use cases—particularly when you just need a repeating callback with minimal transformation logic. It has less conceptual overhead and avoids introducing reactive abstractions where they may not be justified. However, it requires manual lifecycle management (invalidating the timer at the correct moment), is less expressive for chaining asynchronous behavior, and does not integrate naturally with other reactive data flows. Therefore, the imperative timer is often preferable for small, self-contained tasks, while the Combine publisher approach scales better in complex, event-driven architectures.
Search with debounce
Using a reactive approach with Combine (e.g., debounce, removeDuplicates, flatMap) centralizes the entire search pipeline into a single declarative data flow. The main advantage is composability: input normalization, rate limiting, cancellation of in-flight requests, error handling, and threading can all be expressed as operators in a predictable chain. This significantly reduces race conditions and makes it easier to reason about asynchronous behavior. Additionally, flatMap combined with switchToLatest (if used) allows automatic cancellation of outdated requests, which is critical in fast-typing scenarios. The architecture also scales well, especially in MVVM, because the ViewModel cleanly separates input streams from output state.
import Combine
import UIKit
final class SearchViewModel {
let query = PassthroughSubject<String, Never>()
@Published private(set) var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
query
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.removeDuplicates()
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.filter { !$0.isEmpty }
.flatMap { q in
Self.searchPublisher(query: q)
.catch { _ in Just([]) }
}
.assign(to: &$results)
}
private static func searchPublisher(query: String) -> AnyPublisher<[String], Error> {
Future { promise in
DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) {
promise(.success(["\(query) 1", "\(query) 2", "\(query) 3"]))
}
}
.eraseToAnyPublisher()
}
} The main drawback is cognitive overhead and complexity. Combine introduces advanced abstractions (Publishers, Subscribers, Subjects, operator chains) that can be harder to debug and understand compared to imperative callbacks or simple target–action patterns. Error propagation and type signatures can become verbose, and misuse of operators (e.g., forgetting to manage cancellation or incorrectly handling threading) may introduce subtle bugs. For small or straightforward search implementations, a manual debounce using DispatchWorkItem or Timer can be simpler and easier to maintain, particularly for teams not already comfortable with reactive programming paradigms.
final class SearchVC: UIViewController {
private let vm = SearchViewModel()
private var cancellables = Set<AnyCancellable>()
private let textField = UITextField()
override func viewDidLoad() {
super.viewDidLoad()
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
vm.$results
.receive(on: DispatchQueue.main)
.sink { results in
print("Resultados:", results)
}
.store(in: &cancellables)
}
@objc private func textChanged() {
vm.query.send(textField.text ?? "")
}
} Conclusions
With the following examples, I did not intend to directly replace traditional development patterns with Combine, but rather to identify development scenarios where the Combine framework is an appropriate and valuable option.
You can find the source code for this example in the following GitHub repository.
References
- Combine
Apple Developer Documentation