n4nAI

Streaming chat completions in iOS with URLSession bytes

Learn how to implement ios urlsession bytes streaming to consume LLM chat completions in Swift, with runnable code and step-by-step integration guidance.

n4n Team2 min read520 words

Audio narration

Coming soon — every post will get a voice note here.

Wiring LLM responses into an iOS UI shouldn’t require a heavyweight SDK. Implementing ios urlsession bytes streaming for chat completions gives you direct access to Server-Sent Events over HTTPS, with native backpressure and cancellation. This guide builds a minimal Swift client that parses OpenAI-compatible streams and feeds tokens to a SwiftUI view.

Step 1: Define the Request Payload

Use the OpenAI-compatible chat completion format and set "stream": true. This avoids vendor-specific protocols and lets you swap providers by changing the base URL. For instance, n4n.ai provides one OpenAI-compatible endpoint covering 240+ models, so you avoid hardcoding provider URLs in your app binary.

import Foundation

struct Message: Encodable {
    let role: String
    let content: String
}

struct ChatCompletionRequest: Encodable {
    let model: String
    let messages: [Message]
    let stream: Bool = true
}

func makeRequest(messages: [Message], model: String, apiKey: String, url: URL) -> URLRequest {
    var req = URLRequest(url: url)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    let body = ChatCompletionRequest(model: model, messages: messages)
    req.httpBody = try! JSONEncoder().encode(body)
    return req
}

In production, propagate the encode error instead of force-trying. The stream: true flag tells the gateway to emit SSE frames rather than a single JSON blob.

Step 2: Open the Stream with URLSession.bytes

iOS 15+ exposes URLSession.bytes(for:), returning an AsyncBytes sequence. This is the core of ios urlsession bytes streaming: you iterate lines as they arrive from the socket, without loading the entire response into memory. It replaces the older URLSessionDataDelegate dance where you manually buffered Data in didReceive and sliced it on newline boundaries—a pattern that leaks if you forget to reset the buffer.

func streamCompletion(request: URLRequest) async throws -> AsyncThrowingStream<String, Error> {
    let (bytes, response) = try await URLSession.shared.bytes(for: request)
    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    return AsyncThrowingStream { continuation in
        let task = Task {
            do {
                for try await line in bytes.lines {
                    continuation.yield(line)
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
        continuation.onTermination = { _ in task.cancel() }
    }
}

bytes.lines splits on \n and suspends when no data is available, giving you free backpressure. The wrapper converts the sequence into an AsyncThrowingStream so later stages can be composed and cancelled independently.

Step 3: Parse Server-Sent Events

The gateway emits data: {json}\n\n. SSE also permits comment lines starting with : (some providers send : keep-alive); skip anything that doesn’t start with data: . Terminate the loop on data: [DONE].

func parseSSEStream(_ lines: AsyncThrowingStream<String, Error>) async throws -> AsyncThrowingStream<String, Error> {
    AsyncThrowingStream { continuation in
        Task {
            do {
                for try await line in lines {
                    guard line.hasPrefix("data: ") else { continue }
                    let payload = String(line.dropFirst(6))
                    if payload == "[DONE]" {
                        continuation.finish()
                        return
                    }
                    continuation.yield(payload)
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
    }
}

LLM APIs send single-line data frames, so line-based parsing is safe. If you ever see multi-line data: in a custom gateway, accumulate until a blank line before decoding.

Step 4: Decode Tokens from JSON Chunks

A typical OpenAI-compatible chunk looks like this:

{
  "choices": [
    { "delta": { "content": "Hello" } }
  ]
}

Define the minimal decodable shapes and extract the content string:

struct StreamChunk: Decodable {
    struct Choice: Decodable {
        struct Delta: Decodable { let content: String? }
        let delta: Delta
    }
    let choices: [Choice]
}

func tokens(from jsonStream: AsyncThrowingStream<String, Error>) async throws -> AsyncThrowingStream<String, Error> {
    AsyncThrowingStream { continuation in
        Task {
            do {
                for try await json in jsonStream {
                    let data = Data(json.utf8)
                    let chunk = try JSONDecoder().decode(StreamChunk.self, from: data)
                    if let token = chunk.choices.first?.delta.content {
                        continuation.yield(token)
                    }
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
    }
}

The content field is optional because the first chunk often carries only role and no text. Ignore nil silently.

Step 5: Bind to SwiftUI

Create an ObservableObject that accumulates text on the main actor. Because ios urlsession bytes streaming runs off the main thread, hop back with MainActor.run.

import SwiftUI

class ChatViewModel: ObservableObject {
    @Published var output = ""
    private var task: Task<Void, Error>?

    func start(messages: [Message], model: String, apiKey: String, url: URL) {
        task = Task {
            let req = makeRequest(messages: messages, model: model, apiKey: apiKey, url: url)
            let lines = try await streamCompletion(request: req)
            let jsons = try await parseSSEStream(lines)
            let tokenStream = try await tokens(from: jsons)
            for try await token in tokenStream {
                await MainActor.run { self.output += token }
            }
        }
    }

    func cancel() { task?.cancel() }
}

Drive a view:

struct ChatView: View {
    @StateObject var vm = ChatViewModel()
    var body: some View {
        ScrollView { Text(vm.output).padding() }
        .onAppear {
            vm.start(
                messages: [Message(role: "user", content: "Explain Swift concurrency")],
                model: "gpt-4o-mini",
                apiKey: "sk-...",
                url: URL(string: "https://api.openai.com/v1/chat/completions")!
            )
        }
    }
}

If token rate exceeds 60 per second, coalesce updates every 16 ms to cut CPU. SwiftUI handles thousands of small string appends, but profiling on-device is cheap insurance.

Step 6: Handle Errors and Cancellation

Network drops mid-stream are routine. Wrap the session call in do/catch and inspect the status code before consuming bytes. Cancelling the Task propagates into bytes.lines, which throws CancellationError and releases the socket immediately—no stray connections.

If you use a gateway with automatic fallback, like n4n.ai, a provider rate-limit may return a non-200 or a different model’s response; your client should validate HTTPURLResponse.statusCode before iterating, as shown below.

do {
    let (bytes, resp) = try await URLSession.shared.bytes(for: req)
    if let http = resp as? HTTPURLResponse, http.statusCode != 200 {
        throw URLError(.init(rawValue: http.statusCode))
    }
    for try await line in bytes.lines { /* parse */ }
} catch is CancellationError {
    print("stream cancelled by user")
} catch {
    print("stream failed: \(error)")
}

Always call cancel() from your view’s onDisappear to avoid leaking streams when the user navigates away.

Verify Success

Run the app on a simulator or physical device. Open the Xcode console and watch output update token-by-token with sub-second latency. To lock behavior in a unit test, feed a fake line sequence:

func testStreamParsesTokens() async throws {
    let fakeLines = AsyncThrowingStream<String, Error> { cont in
        cont.yield("data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}")
        cont.yield("data: [DONE]")
        cont.finish()
    }
    let jsons = try await parseSSEStream(fakeLines)
    let tokenStream = try await tokens(from: jsons)
    var collected = ""
    for try await t in tokenStream { collected += t }
    XCTAssertEqual(collected, "Hi")
}

If the test passes, tokens render in the UI, and Instruments shows flat memory during a 2,000-token generation, your ios urlsession bytes streaming integration is correct and production-ready.

Tagsiosswiftstreamingurlsession

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All swift & ios llm integration posts →