Issue #1055
Most HTTP requests follow a simple pattern: send a request, wait for a response, close the connection. WebSocket breaks that pattern. The connection stays open after the handshake, and both sides can push messages at any time without waiting for the other to ask. This makes it well-suited for live updates, chat, or streaming AI responses where the server needs to push data as it becomes available.
Apple added native WebSocket support in iOS 13 via URLSessionWebSocketTask. It handles the protocol framing, upgrade negotiation, and ping-pong heartbeats for you, so you work with messages rather than raw bytes.
How it fits in the URLSession stack
URLSessionWebSocketTask sits on top of URLSessionStreamTask, which provides a raw TCP or TLS byte channel. The WebSocket layer adds the framing defined in RFC 6455: each message is prefixed with a header describing its length and type, and client-to-server messages are masked to prevent proxy interference. None of that is your concern when using URLSessionWebSocketTask. You send a String or Data, and the task takes care of the rest.
Setting up a Node.js server
Before writing Swift code, you need a WebSocket server to connect to. The ws package gives you a minimal server in a few lines.
// server.js
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (ws) => {
console.log('client connected')
ws.on('message', (data) => {
const text = data.toString()
console.log('received:', text)
ws.send(`echo: ${text}`)
})
ws.on('close', () => {
console.log('client disconnected')
})
})
console.log('WebSocket server running on ws://localhost:8080')
Install the dependency and run it:
npm install ws
node server.js
The server echoes every message it receives back to the sender with an echo: prefix. That is enough to verify both sending and receiving from the Swift side.
If you want the server to stream messages on its own without waiting for a client message, you can push on an interval instead:
wss.on('connection', (ws) => {
let count = 0
const interval = setInterval(() => {
ws.send(`update ${count++}`)
}, 500)
ws.on('close', () => clearInterval(interval))
})
Creating the task
URLSessionWebSocketTask is created from a URLSession. You pass a ws:// or wss:// URL, then call resume() to open the connection.
let session = URLSession(configuration: .default)
let url = URL(string: "ws://localhost:8080")!
let task = session.webSocketTask(with: url)
task.resume()
At this point the TCP connection is established and the WebSocket handshake has completed. The task is ready to send and receive.
Sending a message
send(_:completionHandler:) accepts a URLSessionWebSocketTask.Message, which is either .string(String) or .data(Data). The async/await version throws on error.
try await task.send(.string("hello"))
You can send structured data by encoding it to JSON first:
let payload = try JSONEncoder().encode(someEncodable)
try await task.send(.data(payload))
Receiving messages
receive() is an async throwing function that suspends until the next message arrives. It returns a URLSessionWebSocketTask.Message.
let message = try await task.receive()
switch message {
case .string(let text):
print("text:", text)
case .data(let data):
print("data:", data)
@unknown default:
break
}
A single call to receive() gets one message. To keep listening, call it in a loop.
Continuous receive loop
Wrapping receive() in a while true loop gives you a stream of incoming messages. The loop exits naturally when the connection closes or an error is thrown.
func listenForMessages(task: URLSessionWebSocketTask) async {
do {
while true {
let message = try await task.receive()
switch message {
case .string(let text):
print("received:", text)
case .data(let data):
print("received data:", data.count, "bytes")
@unknown default:
break
}
}
} catch {
print("connection ended:", error.localizedDescription)
}
}
Call this in a detached task so it does not block your main work:
Task {
await listenForMessages(task: task)
}
If you prefer a value you can iterate over with for await, wrap the receive loop in an AsyncStream:
func messageStream(task: URLSessionWebSocketTask) -> AsyncStream<String> {
AsyncStream { continuation in
Task {
do {
while true {
let message = try await task.receive()
if case .string(let text) = message {
continuation.yield(text)
}
}
} catch {
continuation.finish()
}
}
}
}
// Usage
for await text in messageStream(task: task) {
print(text)
}
This makes the receive side composable and easier to cancel via task cancellation.
Ping and keep-alive
Long-lived connections can be dropped silently by NAT devices or proxies if no traffic flows for a while. Sending periodic pings tells both ends the connection is still alive. URLSessionWebSocketTask can send pings automatically if you set minimumKeepAliveInterval on the session configuration, or you can send them manually:
task.sendPing { error in
if let error {
print("ping failed:", error)
}
}
The server sends a pong automatically per the WebSocket spec, so you only need to handle the error callback.
Closing the connection
Call cancel(with:reason:) when you are done. The close code follows the WebSocket spec; 1000 means a normal closure.
task.cancel(with: .normalClosure, reason: nil)
You can pass a reason as Data if the server needs context, though for most use cases nil is fine.
Start the conversation