How to Build a WebSocket Client in SwiftUI
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
-
@Observable + MainActor mutations —
WebSocketClientis annotated with@Observable(Swift 5.9 Observation framework), so any property change that happens inside aTask { @MainActor in … }closure triggers a targeted SwiftUI re-render with zero@Publisheddeclarations. -
Recursive receive loop —
startListening()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. -
Periodic pings every 25 seconds —
sendPing(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 pingTaskis cancelled indisconnect()to avoid a retain cycle. -
Own vs. server messages —
ChatMessage.isOwndrives 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. -
Graceful teardown with .goingAway —
cancel(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
-
iOS version floor:
URLSessionWebSocketTaskwas introduced in iOS 13, but the Swift 5.9@Observablemacro requires iOS 17. If your deployment target is below iOS 17, fall back toObservableObject + @Published. -
Forgetting to recurse after a successful receive:
receive()fires exactly once per call. Omitting the recursive call (or theawaitloop) means your client reads the first message and goes silent — a notoriously hard bug to spot in testing. -
Main-thread mutations from background callbacks: The completion handler of
receive(completionHandler:)fires on an arbitrary thread. Always hop to@MainActorbefore touching@Observableproperties, otherwise SwiftUI will log a purple runtime warning or silently drop the update. -
ATS / TLS: Plain
ws://connections are blocked by App Transport Security on production builds. Usewss://(TLS) in production, or add an ATS exception for localhost-only debug servers. -
Background state:
URLSessionWebSocketTaskdoes not support background URL sessions. The socket is suspended when your app moves to the background and must be reconnected on foreground. UsescenePhaseto detect this transition and reconnect.
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.