```html SwiftUI: How to Build a WebSocket Client (iOS 17+, 2026)

How to Build a WebSocket Client in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: URLSessionWebSocketTask Updated: May 11, 2026
TL;DR

Create a URLSession.webSocketTask(with:) inside an @Observable class, call resume(), then loop on receive() inside a detached Task to stream messages into your SwiftUI view.

@Observable
final class SocketClient {
    var messages: [String] = []
    private var task: URLSessionWebSocketTask?

    func connect(to url: URL) {
        task = URLSession.shared.webSocketTask(with: url)
        task?.resume()
        listen()
    }

    private func listen() {
        task?.receive { [weak self] result in
            if case .success(let msg) = result,
               case .string(let text) = msg {
                Task { @MainActor in self?.messages.append(text) }
            }
            self?.listen()          // recurse to keep reading
        }
    }

    func send(_ text: String) {
        task?.send(.string(text)) { _ in }
    }
}

Full implementation

The pattern below wraps URLSessionWebSocketTask in a Swift 5.9 @Observable class so every published property automatically drives SwiftUI re-renders without any ObservableObject boilerplate. A recursive callback loop keeps the receive pipe open until you cancel. Periodic pings prevent the OS or server from silently dropping an idle connection.

import SwiftUI
import Observation

// MARK: - Model

enum SocketStatus: String {
    case disconnected = "Disconnected"
    case connecting   = "Connecting…"
    case connected    = "Connected"
    case error        = "Error"
}

struct ChatMessage: Identifiable {
    let id    = UUID()
    let text  : String
    let isOwn : Bool
    let date  : Date = .now
}

// MARK: - Client

@Observable
final class WebSocketClient {
    var messages : [ChatMessage] = []
    var status   : SocketStatus  = .disconnected
    var lastError: String?

    private var task      : URLSessionWebSocketTask?
    private var pingTask  : Task<Void, Never>?

    // Connect to any ws:// or wss:// URL
    func connect(to url: URL) {
        guard task == nil else { return }
        status = .connecting
        lastError = nil

        let session = URLSession(configuration: .default)
        task = session.webSocketTask(with: url)
        task?.resume()

        startListening()
        startPinging()
        status = .connected
    }

    // Send a plain-text message
    func send(_ text: String) {
        guard status == .connected else { return }
        task?.send(.string(text)) { [weak self] error in
            if let error {
                Task { @MainActor in self?.handleError(error) }
            }
        }
        Task { @MainActor in
            messages.append(ChatMessage(text: text, isOwn: true))
        }
    }

    // Graceful close
    func disconnect() {
        pingTask?.cancel()
        pingTask = nil
        task?.cancel(with: .goingAway, reason: nil)
        task = nil
        status = .disconnected
    }

    // MARK: Private

    private func startListening() {
        task?.receive { [weak self] result in
            guard let self else { return }
            switch result {
            case .success(let message):
                switch message {
                case .string(let text):
                    Task { @MainActor in
                        self.messages.append(ChatMessage(text: text, isOwn: false))
                    }
                case .data(let data):
                    // Treat binary as UTF-8 text for demo purposes
                    if let text = String(data: data, encoding: .utf8) {
                        Task { @MainActor in
                            self.messages.append(ChatMessage(text: text, isOwn: false))
                        }
                    }
                @unknown default: break
                }
                self.startListening()   // recurse — keep the pipe open

            case .failure(let error):
                Task { @MainActor in self.handleError(error) }
            }
        }
    }

    private func startPinging() {
        pingTask = Task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(25))
                task?.sendPing { [weak self] error in
                    if let error {
                        Task { @MainActor in self?.handleError(error) }
                    }
                }
            }
        }
    }

    @MainActor
    private func handleError(_ error: Error) {
        lastError = error.localizedDescription
        status = .error
        task = nil
        pingTask?.cancel()
        pingTask = nil
    }
}

// MARK: - View

struct WebSocketView: View {
    @State private var client  = WebSocketClient()
    @State private var draft   = ""
    let url = URL(string: "wss://echo.websocket.org")!

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                statusBar
                messageList
                inputBar
            }
            .navigationTitle("WebSocket Demo")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    connectButton
                }
            }
        }
        .onDisappear { client.disconnect() }
    }

    private var statusBar: some View {
        HStack {
            Circle()
                .fill(client.status == .connected ? Color.green : Color.red)
                .frame(width: 8, height: 8)
            Text(client.status.rawValue)
                .font(.caption)
                .foregroundStyle(.secondary)
            if let err = client.lastError {
                Text("· \(err)")
                    .font(.caption2)
                    .foregroundStyle(.red)
                    .lineLimit(1)
            }
        }
        .padding(.horizontal)
        .padding(.vertical, 6)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.bar)
    }

    private var messageList: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 8) {
                    ForEach(client.messages) { msg in
                        HStack {
                            if msg.isOwn { Spacer() }
                            Text(msg.text)
                                .padding(10)
                                .background(msg.isOwn ? Color.blue : Color(.systemGray5))
                                .foregroundStyle(msg.isOwn ? .white : .primary)
                                .clipShape(RoundedRectangle(cornerRadius: 14))
                                .accessibilityLabel("\(msg.isOwn ? "You" : "Server"): \(msg.text)")
                            if !msg.isOwn { Spacer() }
                        }
                        .id(msg.id)
                    }
                }
                .padding()
            }
            .onChange(of: client.messages.count) {
                withAnimation { proxy.scrollTo(client.messages.last?.id) }
            }
        }
    }

    private var inputBar: some View {
        HStack(spacing: 10) {
            TextField("Message…", text: $draft)
                .textFieldStyle(.roundedBorder)
                .submitLabel(.send)
                .onSubmit(sendDraft)
            Button(action: sendDraft) {
                Image(systemName: "paperplane.fill")
                    .accessibilityLabel("Send message")
            }
            .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty
                      || client.status != .connected)
        }
        .padding()
        .background(.bar)
    }

    private var connectButton: some View {
        Button(client.status == .connected ? "Disconnect" : "Connect") {
            if client.status == .connected { client.disconnect() }
            else { client.connect(to: url) }
        }
    }

    private func sendDraft() {
        let trimmed = draft.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return }
        client.send(trimmed)
        draft = ""
    }
}

// MARK: - Preview

#Preview {
    WebSocketView()
}

How it works

  1. @Observable + MainActor mutationsWebSocketClient is annotated with @Observable (Swift 5.9 Observation framework), so any property change that happens inside a Task { @MainActor in … } closure triggers a targeted SwiftUI re-render with zero @Published declarations.
  2. Recursive receive loopstartListening() calls itself at the end of each successful result, maintaining a continuous read pipe. If the task fails (e.g. network drop), the recursion stops and the error path fires instead of looping forever.
  3. Periodic pings every 25 secondssendPing(pongReceiveHandler:) keeps the underlying TCP connection alive and detects a silently dead server. 25 s sits safely under the typical 30 s server-side idle timeout. The ping Task is cancelled in disconnect() to avoid a retain cycle.
  4. Own vs. server messagesChatMessage.isOwn drives the bubble alignment and colour. Messages you send are appended immediately (optimistic UI); the echo from the server appears as a separate incoming bubble — perfect for testing against echo.websocket.org.
  5. Graceful teardown with .goingAwaycancel(with: .goingAway, reason:) sends a proper WebSocket close frame (opcode 0x8) before tearing down the TCP connection, which is required by RFC 6455 and prevents spurious server-side errors.

Variants

Async/await receive loop (Swift Concurrency style)

If you prefer structured concurrency over callback recursion, use receive() with await inside a loop. The task is stored so it can be cancelled.

private var receiveTask: Task<Void, Never>?

func connect(to url: URL) {
    task = URLSession.shared.webSocketTask(with: url)
    task?.resume()
    receiveTask = Task { [weak self] in
        guard let self else { return }
        while !Task.isCancelled {
            do {
                let message = try await task!.receive()
                if case .string(let text) = message {
                    await MainActor.run {
                        messages.append(ChatMessage(text: text, isOwn: false))
                    }
                }
            } catch {
                await MainActor.run { handleError(error) }
                break
            }
        }
    }
}

func disconnect() {
    receiveTask?.cancel()
    task?.cancel(with: .goingAway, reason: nil)
    task = nil
    status = .disconnected
}

Authenticated connection with custom headers

Many production WebSocket endpoints require a JWT or API key. Build a URLRequest instead of passing a plain URL, then pass it to URLSession.webSocketTask(with: request):

var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("chat, superchat", forHTTPHeaderField: "Sec-WebSocket-Protocol")
task = URLSession.shared.webSocketTask(with: request)

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a WebSocket client in SwiftUI for iOS 17+.
Use URLSessionWebSocketTask.
Wrap the task in an @Observable class with connect(),
send(), and disconnect() methods.
Use a recursive receive loop and periodic sendPing every 25s.
Make it accessible (VoiceOver labels on bubbles and buttons).
Add a #Preview with realistic sample data showing
both sent and received messages.

Drop this prompt into the Build phase in Soarias — Claude Code will scaffold the client class, wire it into your view, and keep the code inside your existing SwiftData or Observation architecture.

Related

FAQ

Does this work on iOS 16?

URLSessionWebSocketTask itself works from iOS 13+. However, the @Observable macro requires iOS 17+. On iOS 16, replace @Observable with final class … : ObservableObject and mark each published property with @Published. All the WebSocket logic stays identical.

How do I automatically reconnect after a network drop?

In handleError(_:), set a short exponential back-off timer and call connect(to:) again. Combine this with NWPathMonitor (Network framework) to wait for connectivity before retrying — otherwise you'll hammer a dead network. Cap retries (e.g. 5×) and surface a permanent error to the user if the server is consistently unreachable.

What is the UIKit / Foundation equivalent?

URLSessionWebSocketTask is pure Foundation — it works identically in UIKit. You'd call the same resume(), receive(), and send() APIs, but dispatch UI updates to DispatchQueue.main.async rather than Task { @MainActor in }. Third-party libraries like Starscream or SwiftNIO WebSocket are alternatives if you need lower-level control or older OS support, but Apple's built-in task is preferred for iOS 17+ apps.

Last reviewed: 2026-05-11 by the Soarias team.

```