Chapter 10 — WebSockets: live notifications

Request/response vs a persistent connection

flowchart LR subgraph HTTP_request_response___HTTP__request_response___ ["HTTP_request_response ["HTTP (request/response)"]"] C1[App] -->|"ask"| S1[Server] S1 -->|"answer, close"| C1 end subgraph WS_WebSocket___WebSocket__persistent__bidirectional___ ["WS_WebSocket ["WebSocket (persistent, bidirectional)"]"] C2[App] <-->|"open once, both push anytime"| S2[Server] end
  • HTTP is a series of short conversations. To learn about a new like, the app would have to keep
  • A WebSocket is one long conversation. The app opens it once; then the server sends a message the

Opening an authenticated connection

// Chirp/Networking/ChirpSocket.swift
import Foundation

actor ChirpSocket {
    private let session: URLSession
    private let url: URL                       // wss://chirp-api.example.com/ws/notifications
    private let tokenProvider: TokenProvider
    private var task: URLSessionWebSocketTask?

    init(session: URLSession = .shared, url: URL, tokenProvider: TokenProvider) {
        self.session = session; self.url = url; self.tokenProvider = tokenProvider
    }

    func connect() async throws {
        var request = URLRequest(url: url)
        let token = try await tokenProvider.validAccessToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        let task = session.webSocketTask(with: request)
        self.task = task
        task.resume()                          // opens the connection (the HTTP → WS "upgrade" handshake)
        schedulePing()                         // keepalive, below
    }

    func disconnect() {
        task?.cancel(with: .goingAway, reason: nil)
        task = nil
    }
}
  • wss://, not https:// — the WebSocket scheme (wss is the TLS-encrypted one; always use it).
  • A WebSocket starts life as an HTTP request that gets "upgraded" to a socket — which is why you can
  • It's an actor because a socket is mutable shared state accessed from multiple tasks (send,

Receiving messages as an async stream

extension ChirpSocket {
    func events() -> AsyncThrowingStream<NotificationEvent, Error> {
        AsyncThrowingStream { continuation in
            let loop = Task { await self.receiveLoop(yielding: continuation) }
            continuation.onTermination = { _ in loop.cancel() }
        }
    }

    private func receiveLoop(yielding continuation: AsyncThrowingStream<NotificationEvent, Error>.Continuation) async {
        guard let task else { continuation.finish(); return }
        do {
            while !Task.isCancelled {
                let message = try await task.receive()          // await the next message
                switch message {
                case .string(let text):
                    if let event = decode(text) { continuation.yield(event) }
                case .data(let data):
                    if let event = decode(data) { continuation.yield(event) }
                @unknown default: break
                }
            }
        } catch {
            continuation.finish(throwing: error)                // connection dropped → stream ends
        }
    }

    private func decode(_ text: String) -> NotificationEvent? { decode(Data(text.utf8)) }
    private func decode(_ data: Data) -> NotificationEvent? { try? JSONDecoder().decode(NotificationEvent.self, from: data) }
}

struct NotificationEvent: Decodable, Identifiable {
    enum Kind: String, Decodable { case like, comment, follow }
    let id: UUID
    let kind: Kind
    let actorUsername: String
    let postID: UUID?
    let createdAt: Date
}
func send(_ event: OutgoingEvent) async throws {
    let data = try JSONEncoder().encode(event)
    try await task?.send(.data(data))
}

Keeping the connection alive: ping/pong

private func schedulePing() {
    Task { [weak task] in
        while task != nil {
            try? await Task.sleep(for: .seconds(30))
            task?.sendPing { error in
                if error != nil { /* ping failed → connection is dead; trigger reconnect */ }
            }
        }
    }
}

Reconnecting when the connection drops

// Chirp/Features/Notifications/NotificationsViewModel.swift
@MainActor @Observable
final class NotificationsViewModel {
    private(set) var events: [NotificationEvent] = []
    private let socket: ChirpSocket
    private var runTask: Task<Void, Never>?

    init(socket: ChirpSocket) { self.socket = socket }

    func start() {
        runTask = Task {
            var backoff = 1.0
            while !Task.isCancelled {
                do {
                    try await socket.connect()
                    for try await event in await socket.events() {    // consume until the stream ends
                        events.insert(event, at: 0)                   // newest first
                        backoff = 1.0                                 // healthy → reset backoff
                    }
                } catch {
                    // Connection failed or dropped.
                }
                // Reconnect after a growing delay (capped), unless we were cancelled.
                if Task.isCancelled { break }
                try? await Task.sleep(for: .seconds(backoff))
                backoff = min(backoff * 2, 30)                        // 1,2,4,…,30s
            }
        }
    }

    func stop() {
        runTask?.cancel(); runTask = nil
        Task { await socket.disconnect() }
    }
}

Lifecycle: connect on screen, disconnect off it

struct NotificationsView: View {
    @State private var viewModel: NotificationsViewModel
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        List(viewModel.events) { event in NotificationRow(event: event) }
            .onAppear { viewModel.start() }
            .onDisappear { viewModel.stop() }
            .onChange(of: scenePhase) { _, phase in
                phase == .active ? viewModel.start() : viewModel.stop()   // reconnect on foreground
            }
    }
}

What we built

  • Understood WebSockets as one persistent, bidirectional connection for server push — the right
  • Opened an authenticated URLSessionWebSocketTask (header on the request, wss://), inside an
  • Turned incoming messages into a typed AsyncThrowingStream of NotificationEvents via a
  • Kept the connection healthy with ping/pong and reconnected with backoff, and tied the socket's

Mental model to take away

  • Use a WebSocket for real-time server push (notifications, chat); use plain HTTP for everything
  • Consume messages via a receive loop → AsyncThrowingStream; when the stream ends, the connection
  • Keep sockets alive with pings and tie them to foreground lifecycle (background push is APNs'