Mobile & Machine
FeaturesLong read

Streaming LLM Responses in SwiftUI With URLSession and AsyncSequence

Stream LLM responses incrementally to avoid blank screens and memory overload.

Features Editor · · 13 min read
Cover illustration for “Streaming LLM Responses in SwiftUI With URLSession and AsyncSequence”
Features · September 18, 2026 · 13 min read · 2,991 words

Streaming a large language model's output is not a cosmetic choice. Streaming a large language model's output makes a chat feature feel alive rather than broken, and on iOS, the plumbing that makes it work runs through URLSession, Server-Sent Events, and Swift's AsyncSequence. Get the pipeline wrong at any layer, from byte parsing to actor isolation to the SwiftUI binding, and the interface either stalls, races, or leaks resources. This piece walks through that pipeline layer by layer, with the architectural discipline the problem actually demands.

Start with the traditional model, because it is the thing streaming replaces. A client sends a request, waits for the full response, then renders it. For most APIs that pattern is fine, because the wait is short. For an LLM generating five hundred words, that wait can run fifteen seconds or longer, and the user spends that entire span staring at a blank screen or a spinner. Streaming flips the sequence: tokens render the moment the model produces them, so the interface starts showing content before generation finishes.

Three concrete gains follow from that shift. The interface shows progress immediately instead of nothing. Memory pressure drops, because the app processes tokens incrementally instead of buffering an entire response in memory before display. And controls like a "stop generation" button become meaningful, because there's an actual in-flight stream to cancel instead of a monolithic request the app can only wait out. The perceptual effect matters as much as the technical one: even when total generation time is identical, an interface that starts rendering immediately feels faster, because the ten-plus second blank screen, not the total latency, is what drives users to give up and close the app. Shipping a non-streaming AI feature in a consumer product today is a self-inflicted UX handicap. The primitives to avoid it already ship in the standard SDK.

Why URLSession.bytes is the right foundation

Before iOS 15, getting a streaming HTTP response meant hand-wiring a data task delegate, tracking partial buffers manually, and reassembling chunks yourself. URLSession.bytes(for:) arrived in iOS 15 and macOS Monterey and made that wiring unnecessary. It returns a tuple: (URLSession.AsyncBytes, URLResponse). Headers and status arrive first, and the body follows as an AsyncSequence of bytes that the app can iterate as they come off the wire.

Contrast that with URLSession.data(for:), which waits for the entire response body before returning anything. data(for:) is a bucket; it fills up, then hands you the whole thing at once. AsyncBytes is a pipe. Bytes flow through it as they arrive, and the app decides what to do with each one instead of waiting for the pipe to fill.

For SSE specifically, AsyncBytes exposes a lines property, an AsyncSequence of newline-delimited strings. That's the natural place to start parsing, because every SSE event is itself newline-terminated. Using lines means the app doesn't have to buffer bytes until it spots a newline; URLSession does that work internally, which cuts one entire class of bugs out of application code.

The iteration syntax looks almost boring, and that's the point:

for try await line in bytes.lines {
    // handle line
}

Structurally, that loop is identical to iterating an array. The await marks the boundary where control yields back to the runtime while waiting on the next line. This is the entry point for the entire pipeline that follows, and everything downstream from here is plain Swift. No third-party networking library is required to make any of this work.

Parsing Server-Sent Events from the byte stream

Most LLM chat APIs, OpenAI's chat completions endpoint being the standard example, deliver streamed responses as SSE. Each event line carries a data: prefix, and the payload is JSON:

data: {"choices":[{"delta":{"content":"word"}}]}

The stream ends with a sentinel line instead of a JSON object:

data: [DONE]

Parsing that shape correctly means handling several small cases in order, and skipping any one of them produces subtle bugs rather than crashes, which makes them worse. Blank lines act as event delimiters in SSE and should just be skipped. Any line that doesn't start with data:, comments, event-type lines, whatever a given provider sends, gets discarded. For lines that do start with data:, strip the prefix and trim whitespace before doing anything else with the remainder.

Then check for [DONE] before attempting to decode anything. That sentinel is not JSON, and running it through a JSON decoder will throw every time. Only after clearing that check should the trimmed payload go through JSONDecoder into a typed struct, from which the app pulls choices[0].delta.content, the actual token text.

The types are small:

struct Chunk: Decodable { let choices: [Choice] }
struct Choice: Decodable { let delta: Delta }
struct Delta: Decodable { let role: String?; let content: String? }

role and content need to be optional, because not every chunk carries both. Some deltas carry only a role announcement early in the stream; others carry content with no role.

The error surface here is narrow but real. Malformed JSON in a partial chunk should never be treated as fatal; skip the chunk and keep the loop running, because dropping one token is far better than killing the whole stream. Unexpected line shapes need the same tolerance. And the [DONE] sentinel has to be checked before decoding, not after a failed decode attempt, or the app ends up treating a clean stream termination as an error.

None of this parsing logic needs to touch the network directly. Written as a pure function, or a namespace of static helpers with no internal state, it becomes trivially testable against a file of recorded SSE payloads, with no live API call required to verify the logic holds.

Wrapping the parsed stream in an AsyncStream the rest of the app can consume

The URLSession.AsyncBytes The URLSession.AsyncBytes loop and the SSE parser are implementation detail. No view model should have to know that SSE exists, or that the payload prefix is data:, or that [DONE] is a special case. Wrapping the whole thing in an AsyncStream<String> gives callers a typed, cancellable sequence of token strings and hides everything else.

The pattern: create the AsyncStream with a continuation, then run the fetch-and-parse loop inside a Task, calling continuation.yield(token) for each token that comes out of the parser and continuation.finish() when the stream ends, errors out, or gets cancelled.

There's a backpressure problem that most sample code skips. AsyncStream does not apply backpressure by default. If SwiftUI's rendering path is slower than the model generating tokens, the continuation's internal buffer grows without bound. On current hardware, the Neural Engine can outrun SwiftUI's text layout engine by 3–4x during burst decode, so this isn't a theoretical edge case; it's a normal operating condition on a fast device.

The fix is a bounded buffering policy:

AsyncStream(String.self, bufferingPolicy: .bufferingNewest(16))

Sixteen tokens gives enough headroom to absorb rendering jitter without letting the queue grow in proportion to model throughput. This detail rarely appears in tutorials, but it separates a demo that works fine on a simulator from a feature that degrades under real load on a physical device. The AsyncStream produced here is the only thing the rest of the app should ever see. Actor code and view model code interact with the stream, never with URLSession internals.

Isolating mutable streaming state in an actor

Tokens arrive on a background execution context. The view model needs to update state that SwiftUI reads on the main actor. Any shared mutable buffer touched from both sides without synchronization is a data race, and under Swift 6's strict concurrency checking, that race is caught at compile time rather than appearing as an intermittent crash in production.

An actor solves this by serializing access: only one task can read or write the message buffer at any moment, and the compiler enforces that boundary without requiring a manual lock anywhere in the code. The typical shape is a ChatStreamManager actor that owns the buffer, drives the AsyncStream consumption loop, and reports updates outward through a @MainActor-annotated callback.

Cancellation is not a nice-to-have here. A live generation task is holding on-device model resources, or an open socket against a remote API, and abandoning that task without explicit cancellation can leave state half-updated. The fix is to hold onto the Task handle, call task.cancel() on user-initiated stop or on view disappearance, and check try Task.checkCancellation() at each iteration inside the loop. That check is what actually makes a "stop generation" button stop the network request and free the resources tied to it, rather than just hiding a spinner while the request keeps running in the background.

Swift 6's strict concurrency checking treats an actor isolation violation, say, a non-isolated closure reaching in to mutate the buffer directly, as a compile-time error rather than a runtime warning. The compiler is catching exactly the class of bug that used to surface as an intermittent crash report from the field, and that should be treated as a correctness signal rather than noise to silence with an @unchecked annotation.

Swift 6.2 changes the annotation burden somewhat. SE-0466 introduces an opt-in default main-actor isolation setting: most application code can become implicitly @MainActor when that compiler setting is enabled, cutting down on explicit annotations scattered through the view model. The actor pattern for the networking-side buffer stays the right approach regardless; what shrinks is how much @MainActor boilerplate the rest of the code needs to carry.

Bridging the stream onto the main actor in SwiftUI with @Observable

The view model is the seam between the actor's world and SwiftUI's. It consumes the AsyncStream and exposes a single property, something like currentResponse: String, that the view reads directly.

Marking that view model @MainActor @Observable does two separate jobs. @MainActor guarantees that mutations affecting the interface happen on the correct thread. @Observable, available from iOS 17 onward, gives SwiftUI property-level change tracking instead of whole-object invalidation.

That distinction changes exactly which views redraw at streaming token rates, since @Observable tracks exactly which properties a given view reads and skips the rest. The older ObservableObject protocol triggers view invalidation on @Published changes in a less granular way than @Observable. @Observable tracks exactly which properties a given view reads, so a chat bubble that only reads currentResponse doesn't re-render when isLoading or some unrelated property flips. At the frame rate tokens can arrive during a burst, that difference is measurable.

For apps still targeting iOS 16, @Observable isn't available, and the fallback is ObservableObject with @Published properties. Point-Free's Perception library back-ports the @Observable-style API to earlier deployment targets under the @Perceptible macro, for teams that want the newer ergonomics without dropping iOS 16 support.

The .task modifier is where the SwiftUI view drives the actual generation call, and it comes with a convenient property: it ties the lifetime of the async work to the view's lifetime. That cancellation propagates up through the view model into the actor, which can close the underlying stream, so leaving the chat screen can tear down the in-flight work instead of leaving it running invisibly.

Rendering itself stays simple. Appending each incoming token to a single String and binding that string to a Text view is the minimal correct pattern; SwiftUI's diffing engine handles the incremental redraw without the developer managing attributed strings or hand-rolled character animation. Auto-scroll follows the same logic: a ScrollViewReader triggered off the same state change that updates currentResponse gives the "follows generation" scroll behavior users expect from chat interfaces, without a separate timer polling for updates.

Handling the full error surface a production chat feature encounters

Errors in a streaming chat feature occur at every layer of the pipeline, and each layer needs its own handling, not one generic catch-all.

At the network layer: gate on HTTPURLResponse.statusCode == 200 before entering the byte loop at all, because trying to parse SSE out of an error response body wastes effort and produces confusing errors downstream. Connection loss mid-stream and timeouts both need explicit handling too, since a partially-received response is a normal event for a chat feature, not an edge case.

At the SSE parsing layer, malformed JSON in a single chunk should be non-fatal, skip it and continue. A missing data: prefix on a line, or a sentinel format that varies from one provider to another, both need graceful handling rather than a crash.

Cancellation deserves its own branch of logic, separate from real errors. A CancellationError thrown because the user tapped stop is not the same class of event as a dropped connection, and the UI should treat them differently: clear the error state silently for a user-initiated cancellation, but display a visible message when an actual network failure occurs.

Concurrent requests are a real failure mode too. If a user triggers a new generation while one is already running, the correct behavior is to cancel the prior task before starting the new one. The view model should hold exactly one live Task handle at any time, never two competing ones.

Error state itself belongs in the view model as a plain, nullable property, something like errorMessage: String?, that the view renders conditionally. Letting an error escape as a thrown exception into SwiftUI's default error boundary is a crash waiting to happen, not an error handling strategy.

Resource cleanup needs to run on every exit path, not just the success path. A defer block inside the actor that resets isGenerating and clears the task handle guarantees the UI never gets stuck showing a loading spinner after a completed, failed, or cancelled generation.

Stamp each generation attempt with a UUID when it starts, and after every await in the pipeline, check that the currently active generation ID still matches before writing any result to the view model. Without that check, a slow response from a request the user already cancelled can land after a newer request's output and silently overwrite it, which is a confusing bug to track down after the fact because nothing throws or crashes when it happens.

The Foundation Models framework as the on-device alternative to URLSession SSE

iOS 26 introduced the Foundation Models framework, which gives Swift direct API access to Apple's on-device model, roughly three billion parameters, the same model that powers Apple Intelligence features elsewhere in the OS. For inference that can run entirely on-device, this is a genuinely different path than the URLSession SSE pipeline described above, not just a variation on it.

The streaming call is session.streamResponse(to:), which returns a ResponseStream, itself an AsyncSequence of snapshot values. The for await loop shape is nearly identical to the URLSession pattern already covered, but the entire layer for parsing the streaming wire format simply doesn't exist, because there's no HTTP transport or wire format to parse.

Where this gets genuinely useful is structured output. With @Generable types, each snapshot carries a partially-generated value where fields become non-nil as the model fills them in, so a view can render a title the moment it's available, before the body text exists at all, without the app ever touching half-formed JSON directly. Property order on a @Generable type is a real streaming UX decision under this model, because the framework generates fields in declaration order, and putting the field a user should see first at the top of the struct is the mechanism that determines what appears on screen first.

Hardware support is not universal. Devices on A16 silicon and earlier don't support Apple Intelligence at all, so gating on SystemLanguageModel.default.availability before ever creating a session is required, and the server-based fallback path needs to be built as a first-class option from the start, not bolted on afterward for unsupported devices.

There's a real trade-off in context window size, too. The on-device model runs with a 4,000-token context window; Private Cloud Compute offers a 32,000-token window along with stronger reasoning capability. That's an architectural decision to make deliberately based on what a given feature needs.

Foundation Models also opened up to outside providers. Anthropic's ClaudeForFoundationModels (in beta), Google's GeminiLanguageModel delivered through Firebase (in preview), and Apple's own CoreAILanguageModel, aimed at local open-weights models running on Apple silicon through the Core AI framework, all conform to the same Language Model protocol, giving developers a choice of backend without rewriting the calling code.

Foundation Models fits on-device inference where the task fits inside a 4K context window, privacy is the top priority, and API key management isn't acceptable. The URLSession SSE pipeline remains the right tool for cloud-only providers without a Foundation Models adapter, for apps still supporting iOS 15 through 25, for custom backends, and for any case where the team controls the inference endpoint directly.

API key security and the proxy pattern for server LLM calls

Shipping an API key inside an app binary is a security failure, not a shortcut. App binaries are extractable; a motivated party can pull strings straight out of the compiled bundle, and once that key is out, whoever controls the account it belongs to is on the hook for whatever usage follows.

Apple's own guidance points away from embedding server credentials on-device, and the standard fix is a proxy: the app calls a small backend service the developer controls, and that backend, holding the real API key server-side, makes the actual call to the LLM provider on the app's behalf. The mobile client never sees the key. This adds a hop to the request path, and for a streaming feature that means the proxy itself needs to support SSE pass-through rather than buffering the full response before relaying it, or the entire benefit of streaming gets lost at that one hop. Built correctly, though, the client-side pipeline described throughout this piece, URLSession.bytes, the SSE parser, the AsyncStream wrapper, the actor, the @Observable view model, works identically whether the far end of the connection is the LLM provider directly or a proxy standing in front of it. The streaming architecture and the security model are separate concerns, and treating them as separate is what lets a team harden one without having to rebuild the other.

Sources

  1. Ship on-device AI with Apple Foundation Models + SwiftUI — MVP Factory
  2. Stop the Wait: Mastering Real-Time AI Token Streaming with Swift and URLSession
  3. Streaming messages from ChatGPT using Swift AsyncSequence
  4. Exploring the Foundation Models framework
  5. Stream Foundation Models Responses Into SwiftUI | The Swift Dev
  6. URLSession.AsyncBytes | Apple Developer Documentation
  7. Use async/await with URLSession - WWDC21 - Videos - Apple Developer
  8. Meet AsyncSequence - WWDC21 - Videos - Apple Developer

More in Features