June 12, 2026
7 Swift Concurrency Bugs That Shipped to Production
And the Patterns That Prevent Them
Jerry PM
10 min read
A missing @MainActor.
A task that ran after the screen was gone. An actor that politely let another caller slip in mid-update. Each of these compiled.
Each one shipped. Each one taught me to stop trusting async/await quite so much.
This is a list of bugs I discovered quite some time ago. They've been sitting in my drafts for a while, and I never had the chance to publish them.
This time, I'm making an effort to finally document and share them.
Swift Concurrency looks safe.
The compiler warns you.
Actors serialize access. async/await reads like sequential code.
After two years of using it daily on a production iOS app real-time price feeds, WebSocket streams, paginated lists, animated UI I've come to a humbler view.
The compiler catches the easy mistakes.
The hard ones still ship.
They look correct, they pass code review, they survive QA, and then a user reports something weird two months later that turns out to be a data race the type checker didn't have enough information to see.
Here are seven of them.
Each section is one real bug the symptom, the code that shipped, the fix, and the pattern that would have caught it earlier.
11 Platforms Where Developers Make Money Beyond Upwork, Fiverr, and the App Stores
1. The @MainActor That Wasn't Where You Thought
The symptom: A view model updated @Published properties from inside a network callback.
Most of the time the UI refreshed fine.
Once a day, on a busy thread, the app crashed with Fatal error: UI API called on a background thread.
Stack trace pointed at the Published setter, but the code looked main-actor isolated.
The code that shipped:
// ❌ Looks fine — but `loadData` is NOT on the main actor
final class PortfolioViewModel: ObservableObject {
@Published var holdings: [Holding] = []
@Published var isLoading: Bool = false
func loadData() async {
isLoading = true
let result = try? await api.fetchHoldings()
holdings = result ?? [] // crash here on a background thread
isLoading = false
}
}// ❌ Looks fine — but `loadData` is NOT on the main actor
final class PortfolioViewModel: ObservableObject {
@Published var holdings: [Holding] = []
@Published var isLoading: Bool = false
func loadData() async {
isLoading = true
let result = try? await api.fetchHoldings()
holdings = result ?? [] // crash here on a background thread
isLoading = false
}
}The class isn't annotated @MainActor.
The function isn't either.
When SwiftUI calls Task { await viewModel.loadData() }, the task starts on whatever executor the caller is on and after await, it resumes on the cooperative thread pool.
The setter fires off the main thread.
The fix:
// ✅ Annotate the class — every method now runs on the main actor
@MainActor
final class PortfolioViewModel: ObservableObject {
@Published var holdings: [Holding] = []
@Published var isLoading: Bool = false
func loadData() async {
isLoading = true
let result = try? await api.fetchHoldings()
holdings = result ?? []
isLoading = false
}
}// ✅ Annotate the class — every method now runs on the main actor
@MainActor
final class PortfolioViewModel: ObservableObject {
@Published var holdings: [Holding] = []
@Published var isLoading: Bool = false
func loadData() async {
isLoading = true
let result = try? await api.fetchHoldings()
holdings = result ?? []
isLoading = false
}
}@MainActor on the class hops every method to the main actor on entry and after every await, it hops back.
The @Published setter is now always on the main thread.
Crash gone.
The lesson: @Published does not guarantee main-thread access. The view model has to.
Annotate the class, not individual functions partial annotation is how this bug crept in.
If your view model owns SwiftUI state, it is a main-actor type.
Mark it once at the class level and stop worrying.
14+ Ways to Turn Claude Code Into a Design Genius And Stop It From Shipping AI Slop
2. The Task That Outlived Its View
The symptom: A user opened a stock detail screen, scrolled the price chart, then quickly tapped back.
Half a second later, an error toast appeared on the next screen:
"Failed to load chart data."
The chart had nothing to do with where they were now.
The code that shipped:
// ❌ Task survives the view dismissal — and updates the wrong screen
struct StockDetailView: View {
@StateObject var viewModel: StockDetailViewModel
var body: some View {
ChartView(data: viewModel.chartData)
.onAppear {
Task {
await viewModel.loadChart() // no cancellation
}
}
}
}// ❌ Task survives the view dismissal — and updates the wrong screen
struct StockDetailView: View {
@StateObject var viewModel: StockDetailViewModel
var body: some View {
ChartView(data: viewModel.chartData)
.onAppear {
Task {
await viewModel.loadChart() // no cancellation
}
}
}
}Task { } creates an unstructured task that has no relationship to the view's lifecycle.
When the view disappears, the task keeps running.
When it eventually finishes and throws, the error handler shows a toast but the user is already on a different screen.
The fix:
// ✅ Use .task — it cancels automatically on view dismissal
struct StockDetailView: View {
@StateObject var viewModel: StockDetailViewModel
var body: some View {
ChartView(data: viewModel.chartData)
.task {
await viewModel.loadChart()
}
}
}
// In the view model, honor cancellation:
func loadChart() async {
do {
try Task.checkCancellation()
let data = try await api.fetchChart(symbol: symbol)
try Task.checkCancellation()
self.chartData = data
} catch is CancellationError {
// expected on dismissal — do nothing
} catch {
self.error = error
}
}// ✅ Use .task — it cancels automatically on view dismissal
struct StockDetailView: View {
@StateObject var viewModel: StockDetailViewModel
var body: some View {
ChartView(data: viewModel.chartData)
.task {
await viewModel.loadChart()
}
}
}
// In the view model, honor cancellation:
func loadChart() async {
do {
try Task.checkCancellation()
let data = try await api.fetchChart(symbol: symbol)
try Task.checkCancellation()
self.chartData = data
} catch is CancellationError {
// expected on dismissal — do nothing
} catch {
self.error = error
}
}.task { } is tied to the view's identity.
When the view goes away, the task is cancelled.
try Task.checkCancellation() before writing back to state means we never publish results to a view that no longer exists.
The lesson: Unstructured Task { } inside onAppear is a leak waiting to happen.
Use .task { } for view-scoped work. And remember: cancellation is cooperative await api.fetchChart() doesn't throw CancellationError automatically. You have to check it.
3. The Actor That Politely Let Two Callers In
The symptom: A token-refresh actor was supposed to dedupe concurrent refresh requests.
Instead, the logs showed two refresh API calls firing within 50ms of each other. Twice the load on the auth endpoint, occasional 429s.
The code that shipped:
// ❌ Looks airtight — but isn't
actor TokenManager {
private var token: String?
private var expiresAt: Date?
func currentToken() async throws -> String {
if let token, let expiresAt, expiresAt > Date() {
return token
}
// Two callers can both reach this point before either finishes
let new = try await api.refreshToken()
self.token = new.value
self.expiresAt = new.expiresAt
return new.value
}
}// ❌ Looks airtight — but isn't
actor TokenManager {
private var token: String?
private var expiresAt: Date?
func currentToken() async throws -> String {
if let token, let expiresAt, expiresAt > Date() {
return token
}
// Two callers can both reach this point before either finishes
let new = try await api.refreshToken()
self.token = new.value
self.expiresAt = new.expiresAt
return new.value
}
}The trap is the await on api.refreshToken().
Inside an actor, code is serialized between awaits not across them.
When one call suspends on await, the actor's mailbox is open.
A second caller can enter currentToken(), see the stale (expired) token, also fail the check, and fire its own refresh.
The fix:
// ✅ Cache the in-flight refresh task so concurrent callers share it
actor TokenManager {
private var token: String?
private var expiresAt: Date?
private var refreshTask: Task<String, Error>?
func currentToken() async throws -> String {
if let token, let expiresAt, expiresAt > Date() {
return token
}
if let existing = refreshTask {
return try await existing.value
}
let task = Task { [weak self] () -> String in
guard let self else { throw CancellationError() }
let new = try await api.refreshToken()
await self.store(token: new.value, expiresAt: new.expiresAt)
return new.value
}
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
private func store(token: String, expiresAt: Date) {
self.token = token
self.expiresAt = expiresAt
}
}// ✅ Cache the in-flight refresh task so concurrent callers share it
actor TokenManager {
private var token: String?
private var expiresAt: Date?
private var refreshTask: Task<String, Error>?
func currentToken() async throws -> String {
if let token, let expiresAt, expiresAt > Date() {
return token
}
if let existing = refreshTask {
return try await existing.value
}
let task = Task { [weak self] () -> String in
guard let self else { throw CancellationError() }
let new = try await api.refreshToken()
await self.store(token: new.value, expiresAt: new.expiresAt)
return new.value
}
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
private func store(token: String, expiresAt: Date) {
self.token = token
self.expiresAt = expiresAt
}
}Now the first caller creates the refresh task and stores it.
The second caller sees a non-nil refreshTask and awaits the same one.
Both get the same result.
One network call.
The lesson: Actors guarantee no-overlap, not no-reentrancy.
Any await inside an actor method is a reentrancy hazard.
When you need
"only one of these in flight at a time,"
cache the in-flight Task, don't trust actor isolation alone.
4. The Sendable Warning You Made Disappear
The symptom: Random crashes deep inside a model class.
Stack traces in Crashlytics that looked impossible properties being read with values they shouldn't be able to hold.
Like a string variable containing a pointer to a deallocated object.
The code that shipped:
// ❌ Captured a non-Sendable class across actor boundaries
class OrderDraft {
var symbol: String = ""
var quantity: Int = 0
}
final class OrderViewModel {
func submit(_ draft: OrderDraft) async throws {
try await orderActor.submit(draft) // Sendable warning suppressed
}
}
actor OrderActor {
func submit(_ draft: OrderDraft) async throws {
// draft.quantity could change mid-flight if the UI mutates it
try await api.submit(symbol: draft.symbol, qty: draft.quantity)
}
}// ❌ Captured a non-Sendable class across actor boundaries
class OrderDraft {
var symbol: String = ""
var quantity: Int = 0
}
final class OrderViewModel {
func submit(_ draft: OrderDraft) async throws {
try await orderActor.submit(draft) // Sendable warning suppressed
}
}
actor OrderActor {
func submit(_ draft: OrderDraft) async throws {
// draft.quantity could change mid-flight if the UI mutates it
try await api.submit(symbol: draft.symbol, qty: draft.quantity)
}
}OrderDraft is a class, not a struct.
When you pass it from the view model (main actor) into the actor, both sides hold the same reference.
The view can keep mutating draft.quantity while the actor is mid-await.
The compiler emitted a non-Sendable type 'OrderDraft' crossing actor boundary warning.
Someone added @unchecked Sendable to silence it.
The fix:
// ✅ Make the cross-boundary type a value type
struct OrderDraft: Sendable {
var symbol: String = ""
var quantity: Int = 0
}
actor OrderActor {
func submit(_ draft: OrderDraft) async throws {
// draft is a copy — mutations in the UI can't reach this scope
try await api.submit(symbol: draft.symbol, qty: draft.quantity)
}
}// ✅ Make the cross-boundary type a value type
struct OrderDraft: Sendable {
var symbol: String = ""
var quantity: Int = 0
}
actor OrderActor {
func submit(_ draft: OrderDraft) async throws {
// draft is a copy — mutations in the UI can't reach this scope
try await api.submit(symbol: draft.symbol, qty: draft.quantity)
}
}Structs are deep-copied at the actor boundary.
The actor gets its own snapshot.
The view can mutate the original all it wants they're now two separate values.
The lesson: @unchecked Sendable is a promise you're making to the compiler.
If you can't explain why the type is safe to share across actors, don't make the promise.
Convert reference types to value types at the boundary. The warning is the bug fix the type, don't silence the diagnostic.
5. Mixing DispatchQueue.main.async With async/await
The symptom: A list view sometimes flashed wrong content for a single frame old data, then new data on slow networks.
Couldn't reproduce reliably. Looked like a SwiftUI rendering quirk. It wasn't.
The code that shipped:
// ❌ Two ways to hop to the main thread, ordered unpredictably
@MainActor
func refresh() async {
let result = try? await api.fetch()
DispatchQueue.main.async {
self.items = result?.cached ?? []
}
self.items = result?.fresh ?? []
}// ❌ Two ways to hop to the main thread, ordered unpredictably
@MainActor
func refresh() async {
let result = try? await api.fetch()
DispatchQueue.main.async {
self.items = result?.cached ?? []
}
self.items = result?.fresh ?? []
}The author wanted "show cached, then show fresh." They reached for DispatchQueue.main.async because it's familiar.
But the function is already @MainActor the second assignment happens immediately, synchronously.
The DispatchQueue block runs on the next main-queue tick, which is after the synchronous assignment.
So we set items = fresh, then items = cached.
The display briefly shows cached data after fresh data.
Backwards.
The fix:
// ✅ Stay in the structured concurrency world
@MainActor
func refresh() async {
let result = try? await api.fetch()
self.items = result?.cached ?? []
await Task.yield() // let SwiftUI render the cached state
self.items = result?.fresh ?? []
}// ✅ Stay in the structured concurrency world
@MainActor
func refresh() async {
let result = try? await api.fetch()
self.items = result?.cached ?? []
await Task.yield() // let SwiftUI render the cached state
self.items = result?.fresh ?? []
}Or, better, don't show cached and fresh as separate states — coalesce them in the model:
self.items = result?.fresh ?? result?.cached ?? []self.items = result?.fresh ?? result?.cached ?? []The lesson: Don't mix DispatchQueue with async/await in the same function.
They have different ordering guarantees.
If you're already in an async function on the main actor, just assign you're already there.
If you need to defer to the next render tick, use await Task.yield(), not GCD.
6. The Task { } That Captured self Forever
The symptom: A user opened and closed a screen 30 times during a debugging session.
Memory in Instruments climbed from 80MB to 240MB.
The view models were never deallocated. Allocations panel showed 30 live StockDetailViewModel instances.
The code that shipped:
// ❌ Long-running Task keeps the view model alive forever
final class StockDetailViewModel: ObservableObject {
@Published var price: Double = 0
private var streamTask: Task<Void, Never>?
init(symbol: String) {
streamTask = Task {
for await tick in priceStream(symbol: symbol) {
self.price = tick // strong reference to self
}
}
}
}// ❌ Long-running Task keeps the view model alive forever
final class StockDetailViewModel: ObservableObject {
@Published var price: Double = 0
private var streamTask: Task<Void, Never>?
init(symbol: String) {
streamTask = Task {
for await tick in priceStream(symbol: symbol) {
self.price = tick // strong reference to self
}
}
}
}Task { } captures self strongly by default.
The price stream never ends it's a live WebSocket.
The task holds self. self holds the task.
Neither side can be released.
Every screen open creates a new view model that never goes away.
The fix:
// ✅ Weak capture + explicit cancellation on deinit
@MainActor
final class StockDetailViewModel: ObservableObject {
@Published var price: Double = 0
private var streamTask: Task<Void, Never>?
init(symbol: String) {
streamTask = Task { [weak self] in
guard let stream = self?.priceStream(symbol: symbol) else { return }
for await tick in stream {
guard let self else { break }
self.price = tick
}
}
}
deinit {
streamTask?.cancel()
}
}// ✅ Weak capture + explicit cancellation on deinit
@MainActor
final class StockDetailViewModel: ObservableObject {
@Published var price: Double = 0
private var streamTask: Task<Void, Never>?
init(symbol: String) {
streamTask = Task { [weak self] in
guard let stream = self?.priceStream(symbol: symbol) else { return }
for await tick in stream {
guard let self else { break }
self.price = tick
}
}
}
deinit {
streamTask?.cancel()
}
}Weak capture breaks the reference cycle. deinit cancels the task explicitly so the underlying stream can close.
Memory drops back to baseline after each screen.
The lesson: Unstructured Task captures self strongly.
If the task is long-running a stream, a polling loop, a subscription you have a retain cycle.
Use [weak self] and cancel in deinit. Better still: use .task { } in SwiftUI, or store tasks in a TaskGroup you tear down explicitly.
7. The AsyncSequence That Never Finished
The symptom: A "search-as-you-type" feature got slower and slower the longer the user typed.
After 10–15 keystrokes, suggestions arrived seconds late.
Memory crept up. The text field was the same; nothing visible had changed.
The code that shipped:
// ❌ Every keystroke launches a new task; old ones never get cancelled
struct SearchView: View {
@State private var query = ""
@State private var results: [Stock] = []
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { _, newValue in
Task {
for try await batch in api.search(newValue) {
results = batch
}
}
}
}
}// ❌ Every keystroke launches a new task; old ones never get cancelled
struct SearchView: View {
@State private var query = ""
@State private var results: [Stock] = []
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { _, newValue in
Task {
for try await batch in api.search(newValue) {
results = batch
}
}
}
}
}Each keystroke spawns a new task.
The old tasks are still running they're still consuming the previous queries' async sequences, still writing to results when batches arrive.
The display flickers between stale results.
CPU climbs because every previous search is still being decoded.
The fix:
// ✅ Cancel the previous task before starting a new one
struct SearchView: View {
@State private var query = ""
@State private var results: [Stock] = []
@State private var searchTask: Task<Void, Never>?
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { _, newValue in
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(250)) // debounce
try Task.checkCancellation()
for try await batch in api.search(newValue) {
try Task.checkCancellation()
results = batch
}
} catch is CancellationError {
// expected — user typed again
} catch {
// real failure
}
}
}
}
}// ✅ Cancel the previous task before starting a new one
struct SearchView: View {
@State private var query = ""
@State private var results: [Stock] = []
@State private var searchTask: Task<Void, Never>?
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { _, newValue in
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(250)) // debounce
try Task.checkCancellation()
for try await batch in api.search(newValue) {
try Task.checkCancellation()
results = batch
}
} catch is CancellationError {
// expected — user typed again
} catch {
// real failure
}
}
}
}
}Three small pieces working together: a stored handle so we can cancel, a 250ms debounce so quick typing doesn't spawn ten requests, and explicit checkCancellation()
so the previous task abandons its work the moment a new one arrives.
The lesson: for await over an AsyncSequence doesn't return until the sequence completes.
If the sequence never completes a server-sent event, a streaming search, a WebSocket every previous loop is still running.
Cancellation has to be explicit.
Always store the task, always cancel the previous one, and always debounce user input before firing an async request.
The Pattern Behind All 7 Bugs
Reading these together, three themes show up over and over:
- The compiler can't see across
await. Bugs #1, #3, and #4 all came from assumptions that held beforeawaitand stopped holding after it.
The actor was exclusive until it suspended.
The view model was main-actor until it resumed on the cooperative pool.
The captured value was stable until something else mutated the reference while we were awaiting.
Every await is a checkpoint where the world can change.
Treat it like one.
2. Lifetimes are the new ownership. Bugs #2, #6, and #7 are all "task lived longer than the thing it was updating."
Swift Concurrency moved memory management from
"who retains whom" to "who cancels whom."
You don't free a task by deallocating it you free it by cancelling it.
If your task can outlive its purpose, you have to give it a way to know.
.task { }, try Task.checkCancellation(), searchTask?.cancel()these are the new weak self.
3. The warning is the bug. Bug #4 is the clearest case, but the pattern repeats.
The compiler tells you something.
You don't understand it.
You silence it.
Six weeks later, production crashes with a stack trace that maps directly back to the warning you suppressed. @unchecked Sendable, nonisolated(unsafe), try! on an actor call every one of these is a foot-gun the compiler offered to stop you from firing.
When you reach for them, stop and ask: do I actually know why this is safe?
1000 Expert Prompts - The Pro Pack for ChatGPT & Claude 1000 role-based, framework-powered prompts across 10 categories. Built for founders, marketers, devs, and creators who…
If this was useful, follow me for weekly iOS articles on Swift, SwiftUI, and the bugs that production teaches you. I write about real code, real fixes, and the patterns that prevent both.
Found this helpful? Share it with another iOS developer & Follow for weekly iOS development content.
Thank you for reading! If you enjoyed it, please consider clapping and following for more updates! 👏