How to stream SSE with URLSession in Swift

Issue #1056

Most HTTP requests are short-lived: send a request, wait for a complete response, close the connection. Server-Sent Events (SSE) breaks that pattern. The server sends a continuous stream of data over a single persistent connection, pushing new content whenever it has something to say. It’s unidirectional (server to client only) and built on plain HTTP, which makes it simpler to set up than WebSockets. It’s also the transport format behind most streaming LLM APIs, including Claude and OpenAI, where the server pushes tokens incrementally as they are generated.

How SSE works

An SSE response uses the text/event-stream content type. The server keeps the connection open and writes newline-delimited text frames. Each frame carries one or more fields, and a blank line signals the end of a message.

The most common fields are:

  • data: - the payload for this event
  • event: - an optional event type name
  • id: - a cursor the client can use to resume from a specific point on reconnect
  • retry: - how many milliseconds to wait before reconnecting if the connection drops

A typical event looks like this on the wire:

data: {"text": "hello"}

data: {"text": " world"}

Each data: line is followed by a blank line. The client reads lines as they arrive and acts on complete messages.

Setting up an Express server

Before writing Swift code, you need a server that actually streams SSE. Express can do this without any extra libraries.

// server.js
const express = require('express')
const app = express()

app.get('/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  res.flushHeaders()

  let count = 0
  const interval = setInterval(() => {
    res.write(`data: update ${count++}\n\n`)
  }, 500)

  req.on('close', () => {
    clearInterval(interval)
    res.end()
  })
})

app.listen(3000, () => console.log('SSE server running on http://localhost:3000'))

Install Express and run it:

npm install express
node server.js

The three headers are required. Content-Type: text/event-stream tells the client this is an SSE stream. Cache-Control: no-cache prevents intermediate proxies from buffering the response. Connection: keep-alive keeps the underlying TCP connection open. res.flushHeaders() sends the headers immediately, before the first res.write() call. Without it, Express may buffer the headers until the response ends, which would break the streaming behavior.

Connecting with URLSession

The modern Swift API for this is URLSession.bytes(for:), introduced in iOS 15 and macOS 12. It returns an AsyncBytes value whose .lines property is an AsyncLineSequence. This sequence yields one line at a time as it arrives over the HTTP connection, without waiting for the full response to complete.

URLSessionStreamTask is a different API. It opens a raw TCP or TLS channel, not an HTTP connection. For SSE over HTTP, bytes(for:).lines is the right tool.

Create a URLRequest with the Accept: text/event-stream header and pass it to URLSession.shared.bytes(for:):

var request = URLRequest(url: URL(string: "http://localhost:3000/stream")!)
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")

let (bytes, response) = try await URLSession.shared.bytes(for: request)

Reading SSE lines

With the response in hand, iterate over the lines:

for try await line in bytes.lines {
    if line.hasPrefix("data:") {
        let payload = line.dropFirst("data:".count).trimmingCharacters(in: .whitespaces)
        print("received:", payload)
    }
}

Each iteration suspends until the next line arrives. Blank lines (the message terminators) arrive as empty strings and can be ignored or used to flush a buffered multi-line event. Lines that don’t start with a recognized field prefix are also ignored per the SSE spec.

You don’t need to manage byte buffering yourself. .lines handles the case where a chunk arrives mid-line, buffering internally until it sees a newline character before yielding.

Wrapping in an AsyncStream

If you want a value you can pass around and for await over at the call site, wrap the bytes loop in an AsyncStream:

func sseStream(url: URL) -> AsyncStream<String> {
    AsyncStream { continuation in
        Task {
            var request = URLRequest(url: url)
            request.setValue("text/event-stream", forHTTPHeaderField: "Accept")

            do {
                let (bytes, _) = try await URLSession.shared.bytes(for: request)
                for try await line in bytes.lines {
                    if line.hasPrefix("data:") {
                        let payload = line.dropFirst("data:".count)
                            .trimmingCharacters(in: .whitespaces)
                        continuation.yield(payload)
                    }
                }
            } catch {
                // connection closed or cancelled
            }

            continuation.finish()
        }
    }
}

Usage at the call site is clean:

let url = URL(string: "http://localhost:3000/stream")!
for await payload in sseStream(url: url) {
    print(payload)
}

To cancel the stream, cancel the enclosing Task. The URLSession request is torn down automatically when the task is cancelled, and the AsyncStream finishes.

Handling multi-field events

Some SSE servers send events with multiple fields before the blank-line terminator. A complete parser buffers the current event, accumulates fields, and dispatches when it sees the blank line:

struct SSEEvent {
    var event: String = "message"
    var data: String = ""
    var id: String = ""
}

for try await line in bytes.lines {
    if line.isEmpty {
        // blank line = dispatch current event
        handleEvent(pending)
        pending = SSEEvent()
    } else if line.hasPrefix("data:") {
        pending.data = line.dropFirst("data:".count)
            .trimmingCharacters(in: .whitespaces)
    } else if line.hasPrefix("event:") {
        pending.event = line.dropFirst("event:".count)
            .trimmingCharacters(in: .whitespaces)
    } else if line.hasPrefix("id:") {
        pending.id = line.dropFirst("id:".count)
            .trimmingCharacters(in: .whitespaces)
    }
}

For simple streaming APIs like LLM token streams, only data: matters and the single-field version is enough.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation