```html SwiftUI: How to Use Server-Sent Events (iOS 17+, 2026)

How to implement server-sent events in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: URLSession Updated: May 11, 2026
TL;DR

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

  1. URLSession.bytes(for:) — Calling URLSession.shared.bytes(for: request) opens a long-lived HTTP connection and returns an AsyncBytes handle. Unlike a normal data task, this never closes until the server ends the stream or the Task is cancelled.
  2. bytes.lines iteration — The for try await line in bytes.lines loop yields each UTF-8 line incrementally as bytes arrive. This is far more memory-efficient than buffering the full response body.
  3. SSE parsing buffer — SSE uses blank lines as event terminators. Lines beginning with data:, event:, id:, and retry: accumulate in local variables; on an empty line the buffered values are dispatched as a single SSEEvent and the buffer resets.
  4. MainActor dispatchawait MainActor.run { self.events.append(sseEvent) } ensures all mutations to the @Observable view model happen on the main thread, keeping SwiftUI observation and rendering thread-safe.
  5. Cancellation via Task — Storing the streaming Task reference and calling .cancel() on it propagates structured-concurrency cancellation into the for try await loop, 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

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.

```