WebSockets in iOS: Swift & iOS best practices explained
Real-time features like live notifications, chat, and collaborative editing require a persistent connection to your backend. URLSessionWebSocketTask gives us a native way to handle WebSockets in iOS without third-party dependencies, but the implementation details matter—connection handling, error recovery, and lifecycle management can quickly become messy if you don't plan ahead. In this article, we'll walk through a production-ready approach to WebSockets in iOS using Swift concurrency patterns that keep your code clean and your connections reliable.
Connect — Create a task from URLSession and resume it
The foundation of WebSocket communication in iOS is URLSessionWebSocketTask. Unlike HTTP requests, WebSocket connections persist, allowing bidirectional messaging. Setting one up is straightforward, but you need to think about where the connection lives in your app's lifecycle.
Create a dedicated WebSocket manager that owns the task and coordinates with your view models or services:
import Foundation
class WebSocketManager: NSObject, ObservableObject {
@Published var isConnected = false
@Published var lastError: Error?
private var webSocket: URLSessionWebSocketTask?
private let url: URL
private let session: URLSession
init(url: URL, session: URLSession = .shared) {
self.url = url
self.session = session
super.init()
}
func connect() {
guard webSocket == nil else { return }
webSocket = session.webSocketTask(with: url)
webSocket?.resume()
DispatchQueue.main.async {
self.isConnected = true
}
}
func disconnect() {
webSocket?.cancel(with: .goingAway, reason: nil)
webSocket = nil
DispatchQueue.main.async {
self.isConnected = false
}
}
}A few key points here: we call resume() immediately after creating the task—without it, nothing happens. We also track whether we're connected via a Published property so SwiftUI can react to connection changes. The URL should be wss:// for production (secure WebSocket) rather than ws://.
Receive in a loop — Wrap receive() in an AsyncStream
The real challenge with WebSockets is handling continuous incoming messages. URLSessionWebSocketTask exposes a receive() method that's async and singular—it waits for one message, then you need to call it again. Without structure, this becomes callback hell. The solution is to wrap it in an AsyncStream and iterate cleanly:
@Published var messages: [WebSocketMessage] = []
func startReceiving() {
Task {
let messageStream = AsyncStream { continuation in
Task {
while webSocket != nil {
do {
guard let message = try? await self.webSocket?.receive() else {
continuation.finish()
return
}
continuation.yield(message)
} catch {
continuation.finish()
DispatchQueue.main.async {
self.lastError = error
self.isConnected = false
}
break
}
}
}
}
for await message in messageStream {
await handleMessage(message)
}
}
}
private func handleMessage(_ message: URLSessionWebSocketTask.Message) async {
switch message {
case .string(let text):
if let data = text.data(using: .utf8),
let decoded = try? JSONDecoder().decode(WebSocketMessage.self, from: data) {
DispatchQueue.main.async {
self.messages.append(decoded)
}
}
case .data(let data):
// Handle binary data if needed
break
@unknown default:
break
}
}
struct WebSocketMessage: Codable {
let id: String
let text: String
let timestamp: Date
}This approach creates a continuous message stream. We yield each received message to the continuation, then iterate over it with a for await loop. If receive() throws an error, we catch it, finish the stream, and mark the connection as dead. This is where real-time data flows into your app in a structured way.
Reconnect — Backoff when the connection drops
Network connections fail—WiFi drops, users move between networks, servers restart. A production app needs automatic reconnection with exponential backoff to avoid hammering the server with reconnect attempts.
private var reconnectAttempt = 0
private let maxReconnectAttempts = 10
private let maxBackoffDelay: TimeInterval = 32
func connectWithRetry() {
Task {
var backoffDelay: TimeInterval = 1
while reconnectAttempt < maxReconnectAttempts {
connect()
startReceiving()
// Wait to see if connection holds
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
if isConnected {
reconnectAttempt = 0 // Reset on success
return
}
reconnectAttempt += 1
backoffDelay = min(backoffDelay * 2, maxBackoffDelay)
DispatchQueue.main.async {
self.lastError = NSError(
domain: "WebSocket",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Reconnecting in \(Int(backoffDelay))s (attempt \(self.reconnectAttempt))"]
)
}
try? await Task.sleep(
nanoseconds: UInt64(backoffDelay * 1_000_000_000)
)
}
DispatchQueue.main.async {
self.lastError = NSError(
domain: "WebSocket",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "Max reconnection attempts reached"]
)
}
}
}
func send(_ message: WebSocketMessage) async throws {
guard let webSocket = webSocket else {
throw NSError(domain: "WebSocket", code: -3)
}
let data = try JSONEncoder().encode(message)
let string = String(data: data, encoding: .utf8) ?? ""
try await webSocket.send(.string(string))
}The backoff strategy starts at 1 second and doubles each time (1s, 2s, 4s, 8s, 16s, 32s) up to a maximum. This gives the server breathing room and reduces load during outages. We also reset the attempt counter on successful connection, so transient failures don't count against us.
Lifecycle — Disconnect in the background, reconnect in foreground
iOS aggressively terminates network connections when apps move to the background. We need to gracefully disconnect and cleanly reconnect when the user returns:
class WebSocketManager: NSObject, ObservableObject {
// ... existing code ...
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
override init(url: URL, session: URLSession = .shared) {
self.url = url
self.session = session
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
@objc private func appDidEnterBackground() {
// Don't keep the connection alive in background
disconnect()
// If you need some background work, wrap it
backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in
self?.endBackgroundTask()
}
}
@objc private func appWillEnterForeground() {
endBackgroundTask()
connectWithRetry() // Reconnect when user returns
}
private func endBackgroundTask() {
if backgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
}
deinit {
NotificationCenter.default.removeObserver(self)
disconnect()
}
}This lifecycle management prevents connection timeouts while the app is backgrounded and ensures we're fresh when the user returns. In a chat or live collaboration app, this keeps your battery usage reasonable without sacrificing responsiveness.
Common mistakes
- Forgetting to call resume(). A new WebSocketTask doesn't connect until you call resume(). This catches everyone once.
- Calling receive() without a loop. If you only call it once and handle one message, the connection stays open but idle. Structure it as a loop or stream.
- No backoff on reconnect. Reconnecting immediately on failure can overwhelm your server and drain battery. Always implement exponential backoff.
- Ignoring background transitions. Leaving a WebSocket open in the background causes unexpected disconnects and wasted resources. Always clean up.
- Not handling errors in the send path. If the connection drops while you're mid-send, you need to catch that and retry or notify the user.
Key takeaways
- URLSessionWebSocketTask is the native iOS way to handle WebSockets—no external dependency needed for basic use cases.
- Wrap the continuous receive() calls in an AsyncStream for clean, structured iteration over incoming messages.
- Implement exponential backoff for reconnection attempts to handle network failures gracefully and respect server load.
- Manage the connection lifecycle with foreground/background notifications to avoid battery drain and unexpected connection state.
- Track connection state and errors via @Published properties so your SwiftUI views can reflect real-time status and guide the user.
Day 59 of the 100-day Swift, SwiftUI & iOS Apps series. WebSockets are a powerful tool for real-time features, and with a solid foundation in connection management, error handling, and lifecycle awareness, you can build responsive, reliable apps that users trust.






Comments
Loading comments…