Swift and iOS WebSocket Guide
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.
Starscream: Popular Third-Party Alternative
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.