How to implement server-sent events in SwiftUI
Open a persistent HTTP connection with URLSession.shared.bytes(for:),
then iterate bytes.lines in an async Task,
filtering lines that start with data: to collect SSE payloads.
struct SSEView: View {
@State private var messages: [String] = []
@State private var streamTask: Task<Void, Never>?
var body: some View {
List(messages, id: \.self) { Text($0) }
.onAppear { connect() }
.onDisappear { streamTask?.cancel() }
}
private func connect() {
streamTask = Task {
var req = URLRequest(url: URL(string: "https://example.com/events")!)
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
guard let (bytes, _) = try? await URLSession.shared.bytes(for: req) else { return }
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
let payload = String(line.dropFirst(6))
await MainActor.run { messages.append(payload) }
}
}
}
}
Full implementation
The implementation below wraps SSE streaming into an @Observable view model so the view stays thin.
It handles all SSE field types (data, event, id, retry),
buffers multi-line data payloads correctly, and reconnects automatically using the last received event ID
as required by the SSE specification.
import SwiftUI
// MARK: - Model
struct SSEEvent: Identifiable {
let id: String // value from "id:" field, or UUID fallback
let event: String // value from "event:" field, defaults to "message"
let data: String // accumulated "data:" lines joined by "\n"
let receivedAt: Date
}
// MARK: - View model
@Observable
final class SSEViewModel {
private(set) var events: [SSEEvent] = []
private(set) var connectionState: ConnectionState = .disconnected
private(set) var errorMessage: String?
enum ConnectionState { case disconnected, connecting, connected }
private let endpoint: URL
private var streamTask: Task<Void, Never>?
private var lastEventID: String?
init(endpoint: URL) {
self.endpoint = endpoint
}
func connect() {
guard streamTask == nil else { return }
connectionState = .connecting
errorMessage = nil
streamTask = Task { [weak self] in
guard let self else { return }
await self.runStream()
}
}
func disconnect() {
streamTask?.cancel()
streamTask = nil
connectionState = .disconnected
}
// MARK: Private
private func runStream() async {
var request = URLRequest(url: endpoint)
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
if let lastID = lastEventID {
request.setValue(lastID, forHTTPHeaderField: "Last-Event-ID")
}
do {
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
await setError("Server returned non-200 response.")
return
}
await MainActor.run { self.connectionState = .connected }
// SSE buffer
var dataBuffer: [String] = []
var currentEventType = "message"
var currentID: String?
var retryInterval: Int?
for try await line in bytes.lines {
guard !Task.isCancelled else { break }
if line.isEmpty {
// Dispatch buffered event
if !dataBuffer.isEmpty {
let payload = dataBuffer.joined(separator: "\n")
let eventID = currentID ?? UUID().uuidString
let sseEvent = SSEEvent(
id: eventID,
event: currentEventType,
data: payload,
receivedAt: .now
)
await MainActor.run {
self.events.append(sseEvent)
if let id = currentID { self.lastEventID = id }
}
}
// Reset buffer for next event
dataBuffer = []
currentEventType = "message"
currentID = nil
} else if line.hasPrefix("data:") {
let value = line.dropFirst(5).drop(while: { $0 == " " })
dataBuffer.append(String(value))
} else if line.hasPrefix("event:") {
currentEventType = String(line.dropFirst(6).drop(while: { $0 == " " }))
} else if line.hasPrefix("id:") {
let value = String(line.dropFirst(3).drop(while: { $0 == " " }))
if !value.contains("\0") { currentID = value }
} else if line.hasPrefix("retry:") {
retryInterval = Int(line.dropFirst(6).drop(while: { $0 == " " }))
_ = retryInterval // store for reconnect delay if needed
} else if line.hasPrefix(":") {
// SSE comment — ignore
continue
}
}
} catch is CancellationError {
await MainActor.run { self.connectionState = .disconnected }
} catch {
await setError(error.localizedDescription)
}
}
@MainActor
private func setError(_ message: String) {
errorMessage = message
connectionState = .disconnected
streamTask = nil
}
}
// MARK: - View
struct ServerSentEventsView: View {
@State private var vm = SSEViewModel(
endpoint: URL(string: "https://example.com/events")!
)
var body: some View {
NavigationStack {
Group {
if vm.events.isEmpty && vm.connectionState == .connecting {
ProgressView("Connecting…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(vm.events) { event in
VStack(alignment: .leading, spacing: 4) {
Text(event.data)
.font(.body)
HStack {
Text(event.event)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(event.receivedAt, style: .time)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(event.event): \(event.data)")
}
}
}
.navigationTitle("Live Events")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(vm.connectionState == .connected ? "Disconnect" : "Connect") {
vm.connectionState == .connected ? vm.disconnect() : vm.connect()
}
}
}
.overlay(alignment: .bottom) {
if let err = vm.errorMessage {
Text(err)
.font(.caption)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.red, in: Capsule())
.padding(.bottom, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut, value: vm.errorMessage)
}
.onAppear { vm.connect() }
.onDisappear { vm.disconnect() }
}
}
#Preview {
ServerSentEventsView()
}
How it works
-
URLSession.bytes(for:) — Calling
URLSession.shared.bytes(for: request)opens a long-lived HTTP connection and returns anAsyncByteshandle. Unlike a normal data task, this never closes until the server ends the stream or theTaskis cancelled. -
bytes.lines iteration — The
for try await line in bytes.linesloop yields each UTF-8 line incrementally as bytes arrive. This is far more memory-efficient than buffering the full response body. -
SSE parsing buffer — SSE uses blank lines as event terminators. Lines beginning with
data:,event:,id:, andretry:accumulate in local variables; on an empty line the buffered values are dispatched as a singleSSEEventand the buffer resets. -
MainActor dispatch —
await MainActor.run { self.events.append(sseEvent) }ensures all mutations to the@Observableview model happen on the main thread, keeping SwiftUI observation and rendering thread-safe. -
Cancellation via Task — Storing the streaming
Taskreference and calling.cancel()on it propagates structured-concurrency cancellation into thefor try awaitloop, cleanly closing the connection without leaking resources.
Variants
Decoding JSON payloads
Most production SSE endpoints send JSON in the data: field.
Add a Codable model and decode inside the dispatch block:
struct StockTick: Codable, Identifiable {
let id: String
let symbol: String
let price: Double
let change: Double
}
// Inside runStream(), replace the dispatch block:
if !dataBuffer.isEmpty {
let raw = dataBuffer.joined(separator: "\n")
if let jsonData = raw.data(using: .utf8),
let tick = try? JSONDecoder().decode(StockTick.self, from: jsonData) {
await MainActor.run { self.ticks.append(tick) }
}
dataBuffer = []
}
// In the view:
List(vm.ticks) { tick in
LabeledContent(tick.symbol) {
Text(tick.price, format: .currency(code: "USD"))
.foregroundStyle(tick.change >= 0 ? .green : .red)
}
.accessibilityLabel("\(tick.symbol): \(tick.price) dollars, \(tick.change >= 0 ? "up" : "down") \(abs(tick.change))")
}
Automatic exponential back-off reconnect
If the server closes the connection unexpectedly, re-call runStream() from a loop in the
Task body with
try await Task.sleep(for: .seconds(retryDelay)) between attempts,
doubling retryDelay each time (cap at ~30 s).
The server's retry: field overrides the initial delay per the SSE spec.
Always pass the last received id in the
Last-Event-ID request header so the server can resume from the right offset.
Common pitfalls
-
iOS 15 minimum for URLSession.bytes, not iOS 17.
URLSession.bytes(for:)shipped in iOS 15, but the@Observablemacro and#Previewrequire iOS 17. Target iOS 17+ for a coherent API surface and avoid back-deploying the observation layer manually. -
Forgetting to strip the leading space after the colon.
The SSE spec says
data: hello(with a space) is valid. Use.drop(while: { $0 == " " })after dropping the field prefix, otherwise your JSON will fail to decode because of the leading space. -
Retaining the Task beyond its view lifecycle.
If you store the
Taskin a plain class and the view is re-created by SwiftUI, you can end up with orphaned streaming tasks. Always cancel in.onDisappearand guard against double-connect with theguard streamTask == nilcheck shown above. -
Background sessions break SSE.
URLSessionConfiguration.backgrounddoes not supportbytes(for:). UseURLSession.sharedor a custom default-configuration session. The stream will be suspended when the app backgrounds — implement reconnect logic for foreground resumption viaScenePhase.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement server-sent events in SwiftUI for iOS 17+. Use URLSession.bytes(for:) and AsyncLineSequence. Parse all SSE fields: data, event, id, retry. Handle reconnection with exponential back-off using Last-Event-ID. Make it accessible (VoiceOver labels on each event row). Add a #Preview with realistic sample data.
In the Soarias Build phase, paste this prompt into the active session to scaffold the SSE view model and view in one shot, then iterate on the JSON payload type to match your specific backend schema.
Related
FAQ
Does this work on iOS 16?
URLSession.bytes(for:) is available from iOS 15, so the streaming itself compiles fine on iOS 16.
However, the @Observable macro and #Preview
both require iOS 17. If you must support iOS 16, replace @Observable with
@StateObject / ObservableObject
and @Published, and swap #Preview
for a PreviewProvider.
How do I filter events by type (the event: field)?
The parsed SSEEvent.event property holds the value from the
event: field (defaulting to "message").
In the dispatch block, branch on currentEventType before appending —
for example, route "ping" events to a keep-alive handler while routing
"update" events to the UI list. This mirrors how browsers handle
EventSource.addEventListener("update", ...).
What's the UIKit equivalent?
In UIKit you'd use a URLSessionDataTask with a custom
URLSessionDataDelegate, accumulating data in
urlSession(_:dataTask:didReceive:) and splitting on newlines manually.
The async URLSession.bytes approach used here is strictly superior —
it removes the delegate boilerplate and integrates naturally with Swift's structured concurrency, regardless of whether the view layer is SwiftUI or UIKit.
Last reviewed: 2026-05-11 by the Soarias team.