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://, nothttps://— the WebSocket scheme (wssis 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
actorbecause 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
AsyncThrowingStreamofNotificationEvents 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'