How do you read a response as a stream with URLSession.bytes, and why does lines skip blank lines when you are parsing server-sent events?
Predict first
Text arrives in three pieces split mid-line. What does lines produce?
import Foundation
final class Chunks: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let r = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/event-stream"])!
client?.urlProtocol(self, didReceive: r, cacheStoragePolicy: .notAllowed)
for piece in ["data: one\nda", "ta: two\n\ndata: thr", "ee\n"] {
client?.urlProtocol(self, didLoad: Data(piece.utf8))
}
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [Chunks.self]
let s = URLSession(configuration: cfg)
let (bytes, _) = try await s.bytes(from: URL(string: "https://stream.test/events")!)
var lines: [String] = []
for try await line in bytes.lines { lines.append(line) }
print(lines)
- ["data: one", "data: two", "data: three"]
- ["data: one", "data: two", "", "data: three"]
- ["data: one\nda", "ta: two\n\ndata: thr", "ee\n"]
Mental model. lines is a reader who tidies the manuscript by deleting blank lines, which is fine for prose and disastrous for a format where a blank line means “end of message”.
session.bytes(for:) returns an AsyncBytes sequence and the response as soon as the headers arrive, so you can process a body incrementally instead of waiting for all of it: server-sent events, newline-delimited JSON, a big download you want to hash while reading, or progress. bytes.lines turns it into an async sequence of Strings split at newlines and handles chunk boundaries for you: in the check the text arrives in three arbitrary pieces ("data: one\nda", "ta: two\n\ndata: thr", "ee\n") and the lines still come out whole: data: one, data: two, data: three.
The trap: lines omits empty lines. The blank line after data: two is gone, but in the SSE format the blank line is the event delimiter. If you parse SSE with lines you cannot tell where one event ends, and multi-line data: events merge. For SSE (and any format where blank lines mean something), iterate the raw bytes and split on \n yourself (keeping empty lines), or use a small parser that accumulates data: fields until it sees an empty line, handles id:, event: and retry:, and treats \r\n as well. Other practical points: cancel the task to close the connection; bytes are not buffered for backpressure, so a slow consumer should not be doing heavy work in the loop; reconnect with Last-Event-ID and backoff; and use URLSession.shared carefully because streaming requests hold a connection (the idle timeout still applies, see the timeout question above).
lines reassembles across chunks and skips empty lines.
Common traps
- Parsing SSE or another blank-line-delimited format with
lines. The delimiters are removed. - Splitting chunks yourself on “\\n”. A chunk can end mid-line; accumulate until you see the delimiter.
They might follow up with
- How is this different from
dataTaskwith a delegate? The delegate gets each chunk callback;bytesis the async/await equivalent with natural cancellation. - When is a WebSocket better? When the client also sends frequent messages; SSE is one-way over plain HTTP (Q321).
Related questions
- 12.9.1 Five requests fail with 401 at the same moment. How do you refresh the token once, retry each request once…
- 12.9.3 What does a multipart/form-data body look like on the wire, and what are the boundary, CRLF and Content-Type…
- 12.4.1 How does URLSessionWebSocketTask work, and what do you have to handle yourself?
- 12.4.2 How do you do multipart uploads, large uploads and downloads, and show progress?
- 10.6.2 How do you bridge repeated events, GCD and Combine into async code with AsyncStream?
← Five requests fail with 401 at the same moment. How do you refresh… What does a multipart/form-data body look like on the wire, and what… →