High‑frequency sensor and telemetry streams will freeze your SwiftUI UI if you feed every sample straight into an @Observable model on @MainActor.
Under Swift 6 strict concurrency, the code that “used to work” by accident also starts failing at compile time: values crossing actor boundaries must be Sendable, and the compiler makes it harder to smuggle mutable state through async call chains. Apple explicitly frames strict concurrency checking as a way to find and fix data races at compile time, not as a performance feature. (developer.apple.com)
The practical outcome is that you need an architecture that is both concurrency‑correct and UI‑friendly: background ingestion that never blocks the main actor, a stream boundary that can drop or buffer, and a main‑actor commit path with strictly bounded work per frame.
The failure mode teams keep shipping
If your stream is “bursty” (Bluetooth packets, motion sensors, network telemetry, watch connectivity), the naïve approach is:
- Receive reading
- Append to an array on a view model
- SwiftUI re-renders
- Repeat, hundreds or thousands of times per second
Two bad things happen:
- Main actor starvation. Your view model mutations and the resulting invalidations saturate the main actor. Apple’s general guidance is to keep long‑running work off the main actor because the UI work inherits main‑actor context (for example, SwiftUI view
bodyis main‑actor isolated). Once you accidentally do heavy work in that context, you get hitches and hangs. (developer.apple.com)
- Strict concurrency stops you “cheating”. Once strict concurrency checking is enabled, moving mutable state between isolated contexts without Sendable guarantees triggers warnings/errors, and the compiler pushes you towards explicit actor isolation and Sendable audits. (developer.apple.com)
The way out is not “sprinkle @MainActor everywhere”. That may fix compilation, but it also concentrates work on the main actor and can make performance worse.
The pattern: ingest → buffer/backpressure → commit (bounded)
The repeatable structure that holds up in production has three layers:
- Ingestion actor that owns the hot path and any mutable aggregation state.
- AsyncSequence boundary that enforces buffering and/or lossiness explicitly.
- Main‑actor model that applies coalesced updates at a controlled cadence.
This maps cleanly onto Swift’s concurrency model and keeps “UI correctness” separate from “stream correctness”. It also aligns with what teams have been asking Apple about in WWDC26 Q&A sessions: how to ingest high‑frequency sensor data on a background actor and stream updates to an @Observable model without blocking the UI. (developer.apple.com)
Design rule: the UI layer must never be your backpressure mechanism
If the UI can’t keep up, you must decide what happens:
- drop samples
- coalesce samples
- downsample
- buffer up to a maximum, then drop
- persist and process offline
If you let SwiftUI invalidations become the implicit buffer, you get unpredictable latency and memory growth.
Layer 1: a dedicated actor for ingestion and aggregation
Make one actor the owner of the stream state. Keep the “hot” mutations there. That does two things:
- It serialises access without locks.
- It prevents accidental main‑actor capture.
Example skeleton:
TelemetryIngestoractor receives raw readings- It maintains a small, mutable aggregation (latest value, rolling average, min/max, etc.)
- It emits snapshots rather than raw samples
A crucial Swift 6 detail: anything that crosses from this actor to another concurrent context should be Sendable, or you will fight the compiler (or silence it with @unchecked Sendable and re‑introduce data races). Swift’s Sendable documentation is explicit: values captured by concurrent closures must be sendable, and conformance must be declared with the type definition. (docs.swift.org)
Practically, that means:
- Prefer
structsnapshots containing value types. - Avoid sending
classreferences unless you can make them immutable and Sendable. - Don’t send framework objects with unclear Sendable status.
Swift 6.3/6.4 continue tightening the ergonomics around these checks and around communicating non‑Sendable intent. For example, Swift Evolution added ~Sendable (implemented in Swift 6.4) as an explicit way for library authors to state a type is deliberately not Sendable. (github.com)
Layer 2: an AsyncSequence boundary with explicit buffering/backpressure
Use an AsyncSequence (often AsyncStream) as the boundary between ingestion and consumption.
The point is not “streams are nice”. The point is you get to choose a buffering policy and isolate where lossiness is allowed.
What you want in high‑frequency UI telemetry is usually:
- a small buffer (to smooth short spikes)
- a clear drop policy (drop oldest or drop newest)
- the ability to cancel cleanly when the view goes away
There is active community attention on backpressure for AsyncStream (including Swift Evolution discussions referenced by Apple Developer Forums tag pages), because default buffering is easy to misuse in high‑rate systems. (developer.apple.com)
A pragmatic policy for UI:
- Buffer size: 1–N snapshots
- Behaviour: keep the latest (dropping intermediates)
That gives you a “latest value wins” stream, which is what most dashboards and live charts actually need.
If your domain cannot drop (for example, financial tick replay, auditing, scientific data capture), do not use a UI‑driven pipeline as your primary storage. Persist raw samples on ingestion, and treat the UI as a derived view.
Layer 3: commit to an @Observable model on the main actor, with bounded work per frame
Your SwiftUI‑facing model should be the smallest thing that makes rendering correct.
Make it main‑actor isolated:
- SwiftUI and UI frameworks expect main‑thread/actor updates.
- Apple’s tooling guidance highlights that tasks created from SwiftUI view contexts inherit main‑actor isolation unless you explicitly hop off. (developer.apple.com)
But keep the work bounded:
- Don’t append unbounded arrays on the main actor.
- Don’t run heavy transforms during property
didSetor@Observablemutations.
A concrete commit strategy that scales
Use a two‑step approach:
- The main‑actor model stores only the latest snapshot (and maybe a small ring buffer for charts).
- UI updates happen at a controlled cadence (for example, per display frame or at a fixed interval), consuming the latest available snapshot.
In practice, you can implement cadence control using:
- a timer (simple, sufficient for many dashboards)
- a display‑link style tick (for tight animation coupling)
- a “coalescing” task that sleeps and then commits the last seen snapshot
The important property is: per tick, you do O(1) work.
End‑to‑end sketch (how the pieces fit)
The architecture looks like this:
- Sensor callback / socket / BLE delegate
TelemetryIngestoractor- normalises raw data
- updates rolling aggregation
- yields
TelemetrySnapshotinto anAsyncStream - A consumer task reads the stream off‑main
- coalesces to “latest only”
- A main‑actor task commits snapshots at a bounded cadence to an
@Observablemodel
If you adopt this structure, Swift 6 strict concurrency becomes an ally:
- Your Sendable boundary is clear and testable.
- You can unit test the ingestion actor without SwiftUI.
- UI correctness is limited to a small, main‑actor isolated surface area.
If you can’t explain where samples are allowed to be dropped, you haven’t designed the pipeline yet.
Trade-offs and when to not do this
This pattern is intentionally “lossy by default” for UI smoothness. That is a trade‑off, and it’s not always acceptable.
Use this pattern when:
- the UI is a live view, not the system of record
- “latest value” is what matters most
- the stream rate can exceed what the UI can render
Do not use this pattern as‑is when:
- every sample must be processed exactly once
- you need strict ordering guarantees end‑to‑end
- you need to run heavy analytics synchronously with capture
In those cases, split the system:
- ingest + persist (lossless)
- process (possibly offline or on a dedicated worker)
- UI subscribes to derived, throttled summaries
Migration notes for teams adopting Swift 6 strict concurrency
A few practical points that reduce pain:
- Treat Sendable as an API design constraint, not a compiler hurdle. The minute you decide what crosses the actor boundary, the compiler stops feeling “random”. (docs.swift.org)
- Avoid
@unchecked Sendableunless you can prove immutability or confinement. It is sometimes necessary, but it is debt. - Be explicit about actor hops. Apple’s strict concurrency adoption guidance is about surfacing data races at compile time; the fix is usually clarifying isolation, not disabling checks. (developer.apple.com)
- Expect ergonomics to keep shifting. Swift 6.3/6.4 added language and tooling refinements (including explicit non‑Sendable signalling via
~Sendablein Swift 6.4). Keep your concurrency boundaries small so you are insulated from churn. (github.com)
Where Codeversols fits (briefly, and honestly)
If you’re building a product where “streaming data into SwiftUI” is core (wearables, medical devices, industrial telemetry, real‑time collaboration), you typically need this architecture plus the unglamorous parts: capture reliability, persistence, replay, test harnesses, and performance tooling.
Codeversols does software engineering across mobile, web, AI and cloud, so we can implement the on‑device pipeline and the backend ingestion/observability that usually goes with it. If you already have a team, we can also help by doing a short concurrency and performance audit and leaving you with a concrete migration plan.
Close
Swift 6 strict concurrency makes the “just update the observable model” approach both riskier and harder to compile, but it also nudges you towards a cleaner separation of concerns.
Put the hot stream on an ingestion actor, make the stream boundary explicit with AsyncSequence buffering/backpressure, and commit to SwiftUI on the main actor with bounded work per frame. The UI stops freezing, and the compiler stops being an adversary because you have clear, Sendable snapshots crossing well‑defined boundaries.



