Jacob’s Tech Tavern

Jacob’s Tech Tavern

Real-time systems with Combine and WebSockets

Three real-world examples

Jacob Bartlett's avatar
Jacob Bartlett
Jun 16, 2025

I always felt like WebSockets were the final boss of software engineering.

It’s giving Kvothe meets Dark Souls. Saint Peter's Basilica is beautiful in June.

Back when I was very junior, I made an app with my friend. Me on the front-end, him on the server. But I couldn’t understand how to get his APIs to work, so he built my networking code for me.

Neither of us really had a clue what we were doing—he’d created a WebSocket server to deliver static JSON. His networking code was littered with Ancien Régime CoreFoundation invocations such as CFReadStreamRef and CFWriteStreamRef.

Back in the day, this was the only way you could do WebSockets. Today, Apple offers us much cuddlier APIs.

I was a mid level developer with a chip on my shoulder when Combine came out in 2019. This was the perfect time for me to enthusiastically apply FP-style operator chains over every API surface I could. If you asked me, I’d insist it was neater. Maybe I even believed it. But we all know the truth: I had to make it more complicated to show I was smarter than everyone.

Nevertheless, Combine (when used appropriately) is one of the best ways to design reactive systems. When used alongside WebSockets, they produce a beautiful harmony. Real-time networking on the backend; reactive UX on the frontend.

Today we will look at my sample project containing three realistic use cases for real-time systems. I set up a local WebSocket server that and 3 simple SwiftUI features. Everything on the iOS side from networking to UI will be powered by Combine.

As always, feel free to check out the example project with all the code here.

Also, there’s several videos in this post which don’t work great in email, so I’d recommend reading the article in your browser.

What are WebSockets?

WebSocket is an application-layer networking protocol, similar to HTTP. Both protocols sit on top of the TCP/IP networking stack. WebSocket connections are persistent and enable 2-way communication between a client and a server.

WebSockets are a great fit for anything real-time, since data continuously flows through the connection without much overhead. This is far more efficient than HTTP polling that requires separate connections and separate overhead for each request.

Until 2019, the only way to set up WebSockets on iOS was handling data using ancient CoreFoundation entities such as CFReadStream and the now-deprecated CFStreamCreatePairWithSocketToHost to instigate the connection. In practice, you’d be using a networking library like Starscream to manage this boilerplate for you:

Gist

Since 2019, we can use the modern URLSessionWebsocketTask API to create and manage WebSocket connections.

On the server side, to make my sample code work, I vibe-coded a simple local WebSocket server with Python and the FastAPI library. This sent a stream of mock data across a few endpoints, one for each of my real-time features.

Our App’s WebSocket Service

To set up the WebSocket listener, we first create an instance of the service for a specified endpoint. Here, since we were running the simulator and listening to a local server, we used a hardcoded local URL. Since it’s a WebSocket URL, it uses the ws:// protocol instead of HTTP.

Gist

URLSessionWebSocketTask is an instance property on our service, and we set it up using URLSession. This high-level interface handles all the TCP connection logic and data streaming for us.

Gist

URLSessionWebSocketTask gives us each message one by one, so we need to run an infinite loop to continue reading these messages. Since we use try, the while loop breaks if there’s an error in the WebSocket task or the socket disconnects.

We have some cleanup logic in the deinit to ensure we don’t risk an infinite loop when we drop a connection.

To receive the message, we simply call the async receive() method on webSocketTask.

Gist

We’re taking data streamed from the WebSocket and passing it to dataSubject, our first Combine entity: a PassthroughSubject.

private let dataSubject = PassthroughSubject<Data, Error>()

In Combine, data flows from Publishers (like these Subjects), through chains of Operators, down to Subscribers which handle the values.

PassthroughSubject (and its glamorous twin, CurrentValueSubject) are two of the most important entities in Combine. You send values through them, which are passed downstream to any operators and listeners. Both stream values to your subscribers, but CurrentValueSubject also caches the most recent value.

These Publishers have broadcast capabilities—that is, each of their listeners receives the values they publish. This is stark contrast to AsyncStream where your code crashes if you try to consume values from more than one place.

You’ll notice this dataSubject is private—it’s an implementation detail. We have a computed publisher property that type-erases this PassthroughSubject to AnyPublisher.

var publisher: AnyPublisher<Data, Error> {
    dataSubject.eraseToAnyPublisher()
}

This type erasure becomes important once you start chaining operators together. Even with a short chain of a few operators, you could end up with a monstrosity like:

Publishers.Debounce<
    Publishers.CombineLatest<
        Publishers.Map<
            PassthroughSubject<Data, Error>,
        ...>, 
    ...>, 
...>

eraseToAnyPublisher() squashes this chain down into:

AnyPublisher<Data, Error>

This is the same thing SwiftUI does with some View. It hides the huge complexity of the nested types in the view hierarchy to make life simpler for developers.

Now let’s look at using this WebSocket data.

Subscribe for free to continue reading

Enter your email and get every post from Jacob’s Tech Tavern.
Already have an account? Sign in
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for writing, podcasting, and media founders.