tests.ws

Swift and iOS WebSocket Guide

swift ios websocket urlsession apple mobile

Swift WebSocket development has become significantly more streamlined since Apple introduced native WebSocket support in iOS 13. Whether you’re building a real-time chat application, live sports updates, or collaborative editing tools, understanding how to implement WebSockets in Swift is essential for modern iOS development.

This guide covers both the native URLSessionWebSocketTask API and the popular Starscream library, providing production-ready patterns for connection management, message handling, and SwiftUI integration.

URLSessionWebSocketTask: Native iOS WebSocket Support

Apple’s native WebSocket implementation arrived with iOS 13, eliminating the need for third-party dependencies in many cases. URLSessionWebSocketTask provides a robust, Apple-maintained solution that integrates seamlessly with the existing URLSession ecosystem.

Creating a WebSocket Connection

The basic setup requires creating a URLSession and establishing a WebSocket task:

import Foundation

class WebSocketManager {
    private var webSocketTask: URLSessionWebSocketTask?
    private let url = URL(string: "wss://echo.websocket.org")!

    func connect() {
        let session = URLSession(configuration: .default)
        webSocketTask = session.webSocketTask(with: url)
        webSocketTask?.resume()

        receiveMessage()
    }

    func disconnect() {
        webSocketTask?.cancel(with: .goingAway, reason: nil)
    }
}

For connections requiring custom headers (authentication tokens, API keys), configure the request before creating the task:

func connectWithHeaders() {
    var request = URLRequest(url: url)
    request.setValue("Bearer token123", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

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

    receiveMessage()
}

Sending Messages

URLSessionWebSocketTask supports both text and binary message formats. Send text messages using the .string case and binary data using .data:

func sendTextMessage(_ message: String) {
    let message = URLSessionWebSocketTask.Message.string(message)
    webSocketTask?.send(message) { error in
        if let error = error {
            print("WebSocket send error: \(error)")
        }
    }
}

func sendBinaryMessage(_ data: Data) {
    let message = URLSessionWebSocketTask.Message.data(data)
    webSocketTask?.send(message) { error in
        if let error = error {
            print("WebSocket send error: \(error)")
        }
    }
}

func sendJSON<T: Encodable>(_ payload: T) {
    do {
        let jsonData = try JSONEncoder().encode(payload)
        let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
        sendTextMessage(jsonString)
    } catch {
        print("JSON encoding error: \(error)")
    }
}

Receiving Messages

Message reception requires a recursive pattern since receive() is a one-time operation. Call receiveMessage() again after each message to maintain continuous listening:

func receiveMessage() {
    webSocketTask?.receive { [weak self] result in
        switch result {
        case .success(let message):
            switch message {
            case .string(let text):
                print("Received text: \(text)")
                self?.handleTextMessage(text)
            case .data(let data):
                print("Received data: \(data.count) bytes")
                self?.handleBinaryMessage(data)
            @unknown default:
                break
            }

            // Continue listening for the next message
            self?.receiveMessage()

        case .failure(let error):
            print("WebSocket receive error: \(error)")
        }
    }
}

func handleTextMessage(_ text: String) {
    guard let data = text.data(using: .utf8) else { return }

    do {
        let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
        // Process JSON message
    } catch {
        print("JSON parsing error: \(error)")
    }
}

func handleBinaryMessage(_ data: Data) {
    // Process binary data
}

Ping and Pong for Connection Health

WebSocket ping/pong frames maintain connection health and detect broken connections. URLSessionWebSocketTask supports manual ping operations:

func sendPing() {
    webSocketTask?.sendPing { error in
        if let error = error {
            print("Ping failed: \(error)")
            // Connection likely dead, attempt reconnection
        } else {
            print("Ping successful")
        }
    }
}

func startHeartbeat() {
    Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
        self?.sendPing()
    }
}

Note that URLSessionWebSocketTask automatically handles pong responses from the server, so you don’t need to manually respond to ping frames.

While URLSessionWebSocketTask works well for iOS 13+, Starscream remains a popular choice for its additional features, cleaner API, and support for older iOS versions. Install via CocoaPods, Carthage, or Swift Package Manager:

// Swift Package Manager
dependencies: [
    .package(url: "https://github.com/daltoniam/Starscream.git", from: "4.0.0")
]

Basic Starscream Implementation

Starscream provides a delegate-based pattern that many developers find more intuitive:

import Starscream

class WebSocketClient: WebSocketDelegate {
    private var socket: WebSocket?

    func connect() {
        var request = URLRequest(url: URL(string: "wss://echo.websocket.org")!)
        request.timeoutInterval = 5
        request.setValue("Bearer token123", forHTTPHeaderField: "Authorization")

        socket = WebSocket(request: request)
        socket?.delegate = self
        socket?.connect()
    }

    func disconnect() {
        socket?.disconnect()
    }

    // MARK: - WebSocketDelegate

    func didReceive(event: WebSocketEvent, client: WebSocket) {
        switch event {
        case .connected(let headers):
            print("WebSocket connected: \(headers)")
        case .disconnected(let reason, let code):
            print("WebSocket disconnected: \(reason) (code: \(code))")
        case .text(let string):
            print("Received text: \(string)")
        case .binary(let data):
            print("Received data: \(data.count) bytes")
        case .ping(_):
            print("Received ping")
        case .pong(_):
            print("Received pong")
        case .viabilityChanged(_):
            break
        case .reconnectSuggested(_):
            connect()
        case .cancelled:
            print("WebSocket cancelled")
        case .error(let error):
            print("WebSocket error: \(error?.localizedDescription ?? "unknown")")
        case .peerClosed:
            print("Peer closed connection")
        }
    }

    func send(_ message: String) {
        socket?.write(string: message)
    }

    func sendData(_ data: Data) {
        socket?.write(data: data)
    }
}

SwiftUI Integration

Integrate WebSockets into SwiftUI using ObservableObject and @Published properties for reactive UI updates:

import SwiftUI
import Combine

class WebSocketViewModel: ObservableObject {
    @Published var connectionStatus: String = "Disconnected"
    @Published var messages: [String] = []
    @Published var isConnected: Bool = false

    private var webSocketTask: URLSessionWebSocketTask?
    private let url = URL(string: "wss://echo.websocket.org")!

    func connect() {
        let session = URLSession(configuration: .default)
        webSocketTask = session.webSocketTask(with: url)
        webSocketTask?.resume()

        DispatchQueue.main.async {
            self.isConnected = true
            self.connectionStatus = "Connected"
        }

        receiveMessage()
    }

    func disconnect() {
        webSocketTask?.cancel(with: .goingAway, reason: nil)

        DispatchQueue.main.async {
            self.isConnected = false
            self.connectionStatus = "Disconnected"
        }
    }

    func send(_ message: String) {
        let message = URLSessionWebSocketTask.Message.string(message)
        webSocketTask?.send(message) { error in
            if let error = error {
                print("Send error: \(error)")
            }
        }
    }

    private func receiveMessage() {
        webSocketTask?.receive { [weak self] result in
            switch result {
            case .success(let message):
                switch message {
                case .string(let text):
                    DispatchQueue.main.async {
                        self?.messages.append(text)
                    }
                case .data(let data):
                    if let text = String(data: data, encoding: .utf8) {
                        DispatchQueue.main.async {
                            self?.messages.append(text)
                        }
                    }
                @unknown default:
                    break
                }
                self?.receiveMessage()
            case .failure(let error):
                DispatchQueue.main.async {
                    self?.connectionStatus = "Error: \(error.localizedDescription)"
                    self?.isConnected = false
                }
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var viewModel = WebSocketViewModel()
    @State private var messageText = ""

    var body: some View {
        VStack {
            Text(viewModel.connectionStatus)
                .padding()

            HStack {
                Button(viewModel.isConnected ? "Disconnect" : "Connect") {
                    if viewModel.isConnected {
                        viewModel.disconnect()
                    } else {
                        viewModel.connect()
                    }
                }
                .padding()
            }

            List(viewModel.messages, id: \.self) { message in
                Text(message)
            }

            HStack {
                TextField("Message", text: $messageText)
                    .textFieldStyle(RoundedBorderTextFieldStyle())

                Button("Send") {
                    viewModel.send(messageText)
                    messageText = ""
                }
                .disabled(!viewModel.isConnected)
            }
            .padding()
        }
    }
}

Background Modes and State Handling

iOS applications face significant restrictions when running in the background. WebSocket connections typically close when the app enters the background unless you configure background modes.

Enabling Background Modes

Add the Background Modes capability in Xcode and enable Background fetch or VoIP. For most WebSocket applications, configure proper state handling:

import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    var webSocketManager: WebSocketManager?

    func applicationDidEnterBackground(_ application: UIApplication) {
        // iOS will suspend the app and close WebSocket connections
        // Save state and prepare for reconnection
        webSocketManager?.disconnect()
    }

    func applicationWillEnterForeground(_ application: UIApplication) {
        // Reconnect when app returns to foreground
        webSocketManager?.connect()
    }
}

For SwiftUI lifecycle apps, use scene phase detection:

@main
struct MyApp: App {
    @StateObject private var webSocketViewModel = WebSocketViewModel()
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(webSocketViewModel)
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            switch newPhase {
            case .active:
                webSocketViewModel.connect()
            case .background:
                webSocketViewModel.disconnect()
            case .inactive:
                break
            @unknown default:
                break
            }
        }
    }
}

Reconnection Strategy

Production WebSocket implementations require robust reconnection logic to handle network interruptions, server restarts, and connection timeouts:

class ReliableWebSocketManager: ObservableObject {
    @Published var isConnected = false

    private var webSocketTask: URLSessionWebSocketTask?
    private let url = URL(string: "wss://echo.websocket.org")!
    private var reconnectAttempts = 0
    private let maxReconnectAttempts = 5
    private var reconnectTimer: Timer?

    func connect() {
        let session = URLSession(configuration: .default)
        webSocketTask = session.webSocketTask(with: url)
        webSocketTask?.resume()

        reconnectAttempts = 0
        isConnected = true
        receiveMessage()
    }

    func disconnect() {
        reconnectTimer?.invalidate()
        webSocketTask?.cancel(with: .goingAway, reason: nil)
        isConnected = false
    }

    private func receiveMessage() {
        webSocketTask?.receive { [weak self] result in
            switch result {
            case .success(let message):
                // Handle message
                self?.receiveMessage()
            case .failure(let error):
                print("WebSocket error: \(error)")
                self?.handleConnectionFailure()
            }
        }
    }

    private func handleConnectionFailure() {
        isConnected = false

        guard reconnectAttempts < maxReconnectAttempts else {
            print("Max reconnection attempts reached")
            return
        }

        reconnectAttempts += 1
        let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0) // Exponential backoff, max 30s

        print("Reconnecting in \(delay) seconds (attempt \(reconnectAttempts))")

        reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
            self?.connect()
        }
    }
}

Error Handling and Close Codes

Proper error handling distinguishes production-ready code from prototypes. Handle various error scenarios and close codes appropriately:

func handleWebSocketError(_ error: Error) {
    if let urlError = error as? URLError {
        switch urlError.code {
        case .notConnectedToInternet:
            print("No internet connection")
        case .networkConnectionLost:
            print("Connection lost, attempting reconnect")
            handleConnectionFailure()
        case .timedOut:
            print("Connection timed out")
            handleConnectionFailure()
        case .cannotFindHost, .cannotConnectToHost:
            print("Cannot reach server")
        default:
            print("URL error: \(urlError.localizedDescription)")
        }
    }
}

func disconnect(with closeCode: URLSessionWebSocketTask.CloseCode) {
    let reason = "Client closing connection".data(using: .utf8)
    webSocketTask?.cancel(with: closeCode, reason: reason)
}

// Common close codes
func closeNormally() {
    disconnect(with: .normalClosure) // 1000
}

func closeGoingAway() {
    disconnect(with: .goingAway) // 1001
}

func closeProtocolError() {
    disconnect(with: .protocolError) // 1002
}

For more details on WebSocket close codes, see the WebSocket Close Codes reference.

Complete Production Example

Here’s a complete, production-ready WebSocket manager combining all best practices:

import Foundation
import Combine

final class ProductionWebSocketManager: ObservableObject {
    @Published private(set) var connectionState: ConnectionState = .disconnected
    @Published private(set) var messages: [Message] = []

    private var webSocketTask: URLSessionWebSocketTask?
    private let url: URL
    private var reconnectAttempts = 0
    private let maxReconnectAttempts = 5
    private var heartbeatTimer: Timer?
    private var reconnectTimer: Timer?

    enum ConnectionState {
        case connected
        case connecting
        case disconnected
        case error(String)
    }

    struct Message: Identifiable {
        let id = UUID()
        let content: String
        let timestamp: Date
        let isOutgoing: Bool
    }

    init(url: URL) {
        self.url = url
    }

    func connect() {
        guard connectionState != .connected && connectionState != .connecting else { return }

        connectionState = .connecting

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

        connectionState = .connected
        reconnectAttempts = 0

        startHeartbeat()
        receiveMessage()
    }

    func disconnect() {
        stopHeartbeat()
        reconnectTimer?.invalidate()

        webSocketTask?.cancel(with: .goingAway, reason: nil)
        connectionState = .disconnected
    }

    func send(_ text: String) {
        let message = URLSessionWebSocketTask.Message.string(text)

        webSocketTask?.send(message) { [weak self] error in
            DispatchQueue.main.async {
                if let error = error {
                    self?.connectionState = .error(error.localizedDescription)
                } else {
                    self?.messages.append(Message(
                        content: text,
                        timestamp: Date(),
                        isOutgoing: true
                    ))
                }
            }
        }
    }

    private func receiveMessage() {
        webSocketTask?.receive { [weak self] result in
            switch result {
            case .success(let message):
                switch message {
                case .string(let text):
                    DispatchQueue.main.async {
                        self?.messages.append(Message(
                            content: text,
                            timestamp: Date(),
                            isOutgoing: false
                        ))
                    }
                case .data(let data):
                    if let text = String(data: data, encoding: .utf8) {
                        DispatchQueue.main.async {
                            self?.messages.append(Message(
                                content: text,
                                timestamp: Date(),
                                isOutgoing: false
                            ))
                        }
                    }
                @unknown default:
                    break
                }
                self?.receiveMessage()

            case .failure(let error):
                DispatchQueue.main.async {
                    self?.handleConnectionFailure(error)
                }
            }
        }
    }

    private func startHeartbeat() {
        heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
            self?.sendPing()
        }
    }

    private func stopHeartbeat() {
        heartbeatTimer?.invalidate()
        heartbeatTimer = nil
    }

    private func sendPing() {
        webSocketTask?.sendPing { [weak self] error in
            if let error = error {
                print("Ping failed: \(error)")
                self?.handleConnectionFailure(error)
            }
        }
    }

    private func handleConnectionFailure(_ error: Error) {
        stopHeartbeat()
        connectionState = .error(error.localizedDescription)

        guard reconnectAttempts < maxReconnectAttempts else {
            connectionState = .error("Max reconnection attempts reached")
            return
        }

        reconnectAttempts += 1
        let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0)

        reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
            self?.connect()
        }
    }
}

Testing Your WebSocket Implementation

Testing WebSocket functionality requires different approaches than REST APIs. Use echo servers for basic testing and dedicated tools for comprehensive validation:

// Use echo server for testing
let testURL = URL(string: "wss://echo.websocket.org")!

// Or your own test endpoint
let devURL = URL(string: "ws://localhost:8080")!

For comprehensive testing strategies and tools, see How to Test WebSockets.

Frequently Asked Questions

Does URLSessionWebSocketTask work on macOS and tvOS?

Yes, URLSessionWebSocketTask is available on iOS 13+, macOS 10.15+, tvOS 13+, and watchOS 6+. It’s part of the Foundation framework and works across all Apple platforms.

How do I handle SSL/TLS certificate validation?

Use URLSessionDelegate methods to implement custom certificate validation:

class WebSocketManager: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession,
                   didReceive challenge: URLAuthenticationChallenge,
                   completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

        if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
           let serverTrust = challenge.protectionSpace.serverTrust {
            let credential = URLCredential(trust: serverTrust)
            completionHandler(.useCredential, credential)
        } else {
            completionHandler(.performDefaultHandling, nil)
        }
    }
}

What’s the maximum message size for URLSessionWebSocketTask?

URLSessionWebSocketTask doesn’t impose a hard limit on message size, but practical limits depend on available memory. For large payloads, consider chunking messages or using binary format. Messages over 10MB may cause performance issues on memory-constrained devices.

Should I use URLSessionWebSocketTask or Starscream?

Use URLSessionWebSocketTask for new projects targeting iOS 13+ that need simple WebSocket functionality without external dependencies. Choose Starscream if you need iOS 8-12 support, compression extensions, or prefer its delegate-based API. Both are production-ready; URLSessionWebSocketTask benefits from Apple’s ongoing maintenance and security updates.

Additional Resources

For foundational WebSocket concepts, see What is WebSocket. Understanding the protocol helps debug connection issues and optimize your implementation for specific use cases.

Swift’s native WebSocket support makes real-time communication accessible without sacrificing type safety or performance. Whether building chat applications, live dashboards, or collaborative tools, the patterns in this guide provide a solid foundation for production iOS WebSocket clients.