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?

12.9.2 · L3 · Interview

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 dataTask with a delegate? The delegate gets each chunk callback; bytes is 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… →