Skip to main content

2 posts tagged with "Swift Concurrency"

View All Tags

Eliminating @MainActor Violations: Strict Concurrency Checks and Main Thread Checker in iOS

Published: · 9 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Modern Swift Concurrency makes it easier to write safe, responsive iOS apps - but only if you respect actor isolation. The most common pitfall is touching UI from a background context, which surfaces as an @MainActor violation at compile-time or a Main Thread Checker warning at runtime. With Swift 6 strict concurrency checks in Xcode and the Main Thread Checker, you can catch these issues before they reach production.

This guide explains how to identify, fix, and prevent @MainActor violations using Swift Concurrency, Strict Concurrency Checking, and Xcode’s diagnostics. We’ll cover practical patterns for SwiftUI and UIKit, architectural guidance, and production-ready code you can drop into your app today.

Prerequisites

  • Xcode 16+ (Swift 6 language mode available; Swift 5.10 in Swift 5 mode)
  • iOS 15+ (async/await, actors)
  • Enable Strict Concurrency Checking in Build Settings:
    • Swift 5 mode: Swift Compiler – Warnings: Strict Concurrency Checking = Complete (preferably as Errors in CI)
    • Swift 6 mode: Concurrency checking is stricter by default; expect more issues to be surfaced as errors
  • Scheme > Run > Diagnostics: enable Main Thread Checker for runtime verification

What counts as an @MainActor violation?

  • UI frameworks (UIKit, SwiftUI, AppKit) are main-actor-isolated. Their APIs must be called from the main actor.
  • Many APIs are explicitly or implicitly annotated with @MainActor. Examples include UIApplication/shared state, some UI state mutations, and SwiftUI view updates.
  • A call to main actor-isolated method from a background context triggers a compiler error in Swift 6 (or warnings in Swift 5 with strict checking) and/or a runtime main thread checker warning in Xcode.

Typical errors and warnings you’ll see:

  • Compiler: “Call to main actor-isolated method ‘reloadData()’ in a synchronous nonisolated context”
  • Runtime: “Main Thread Checker: UI API called on a background thread”

Why the Main Actor exists

The main actor serializes access to UI state. It’s the concurrency-safe replacement for “dispatch everything onto DispatchQueue.main.” Instead of manually juggling queues, you:

  • Isolate UI-bound types with @MainActor.
  • Hop to the main actor (await MainActor.run or Task { @MainActor in ... }) when you must touch UI from background work.
  • Let the compiler help you avoid data races and invalid UI access.

Turn on swift 6 strict concurrency checks

To catch problems early:

  • In Swift 5 mode:

    • Build Settings > Swift Compiler – Warnings > Strict Concurrency Checking = Complete
    • Treat Warnings as Errors in CI to enforce correctness
  • In Swift 6 language mode (Xcode 16+):

    • Concurrency checks are stricter by default. Many violations become errors.
    • Expect to add annotations (@MainActor, @Sendable, nonisolated) and “await” markers where the compiler now requires them.

Tip: In Xcode, “Fix” suggestions often offer the correct remedy - either add await, mark a function or type @MainActor, or explicitly hop to the main actor.

Using the Main Thread Checker in Xcode

  • Enable: Scheme > Run > Diagnostics > Main Thread Checker
  • Reproduce UI flows. Watch the console for “main thread checker warning xcode” messages and call stacks.
  • The checker catches runtime-only issues (e.g., in code paths the compiler can’t fully analyze or in third-party libraries without annotations).

Use both: Swift 6 strict concurrency checks at compile time, and Main Thread Checker at runtime.

How to fix @MainActor violations in Swift

1. Isolate UI-bound types with @MainActor

For SwiftUI or UIKit, types that own UI state or trigger UI updates should be @MainActor. This includes:

  • View models that mutate @Published state consumed by views
  • Coordinators, presenters, and controllers triggering navigation or alerts

Example: SwiftUI MVVM

// Xcode 16+, iOS 15+

protocol SearchService: Sendable {
func search(query: String) async throws -> [ResultItem]
}

struct ResultItem: Identifiable, Sendable {
let id: UUID
let title: String
}

@MainActor
final class SearchViewModel: ObservableObject {
@Published private(set) var results: [ResultItem] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?

private let service: SearchService
private var currentTask: Task<Void, Never>?

init(service: SearchService) {
self.service = service
}

func search(query: String) {
currentTask?.cancel()
isLoading = true
errorMessage = nil

currentTask = Task { [service] in
do {
// This call runs off the main actor while awaited,
// and the main actor is free to process UI events.
let items = try await service.search(query: query)
// When execution resumes here, we're back on the main actor
// because the ViewModel is @MainActor.
self.results = items
} catch is CancellationError {
// Ignore
} catch {
self.errorMessage = "Search failed: \(error.localizedDescription)"
}
self.isLoading = false
}
}

deinit {
currentTask?.cancel()
}
}

Why this avoids violations:

  • All UI-observable mutations (@Published properties) occur under @MainActor.
  • Awaited work runs off-main while suspended, preventing UI hitches.
  • No explicit MainActor.run needed because the type is main-actor-isolated.

SwiftUI View:

struct SearchView: View {
@StateObject private var viewModel: SearchViewModel

init(service: SearchService) {
_viewModel = StateObject(wrappedValue: SearchViewModel(service: service))
}

var body: some View {
VStack {
// ... search field ...
if viewModel.isLoading {
ProgressView()
}
List(viewModel.results) { item in
Text(item.title)
}
if let error = viewModel.errorMessage {
Text(error).foregroundColor(.red)
}
}
.task {
// Optionally kick off an initial load
await Task.yield()
}
}
}

2. Hop to the main actor from background contexts

When you must update UI from a non-main actor, perform an explicit hop.

Two primary tools:

  • await MainActor.run { ... }
  • Task { @MainActor in ... }

mainactor.run vs task

  • MainActor.run
    • Suspends the current task, executes the closure on the main actor, then resumes.
    • Use when you need a result back or when performing a small, synchronous UI mutation in the middle of an async flow.
    • Example:
let processed = try await imageProcessor.process(input)
await MainActor.run {
imageView.image = processed // UI update safely on main actor
}
  • Task { @MainActor in ... }
    • Creates a new, independent task bound to the main actor (fire-and-forget).
    • Use for notifications, navigation triggers, or UI updates that don’t need to block the current task.
    • Example:
Task { @MainActor in
self.tableView.reloadData()
}

Rule of thumb:

  • Need to return a value or order UI updates in the same async flow? Use MainActor.run.
  • Fire-and-forget UI work or decouple from the current task? Use Task { @MainActor in }.

3. Annotate closures and APIs with global actors

If you own the API surface, annotate callbacks that will run on the main actor:

struct BannerPresenter {
// Consumers know the closure runs on the main actor.
func present(message: String, completion: @MainActor @escaping () -> Void) {
Task { @MainActor in
// Present on main
// ...
completion()
}
}
}

This reduces surprises and prevents downstream @MainActor violation sites.

4. Don’t blanket @MainActor everything

Over-annotating non-UI code with @MainActor can:

  • Introduce unnecessary serialization and contention
  • Risk responsiveness issues if heavy work runs under the main actor
  • Complicate testing

Keep data, networking, and computation off the main actor:

  • Use plain types or custom actors for stateful, concurrent components
  • Keep view models @MainActor, but services/clients should be Sendable and not main-isolated
actor ImageCache {
private var storage: [URL: Data] = [:]
func data(for url: URL) -> Data? { storage[url] }
func insert(_ data: Data, for url: URL) { storage[url] = data }
}

5. UIKit patterns

UIKit isn’t automatically “safe” in Swift Concurrency. Explicitly switch to main when needed.

final class PhotoListViewController: UITableViewController {
private let service: PhotoService

init(service: PhotoService) {
self.service = service
super.init(style: .plain)
}

required init?(coder: NSCoder) { fatalError() }

override func viewDidLoad() {
super.viewDidLoad()

Task {
do {
let items = try await service.fetchPhotos()
await MainActor.run {
self.applySnapshot(with: items) // Diffable data source update
}
} catch {
await MainActor.run {
self.presentError(error)
}
}
}
}

@MainActor
private func applySnapshot(with items: [Photo]) {
// Safe to mutate UI state here
// ...
tableView.reloadData()
}

@MainActor
private func presentError(_ error: Error) {
let alert = UIAlertController(title: "Error",
message: error.localizedDescription,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}

6. Combine bridges

When bridging Combine pipelines:

  • Use .receive(on:) to hop to the main run loop if you can’t migrate to async/await yet.
  • For async/await, prefer handling values in async contexts and use MainActor.run for UI updates.
// Combine: ensure downstream UI updates are on main
publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] value in
self?.label.text = value
}
.store(in: &cancellables)

7. Core Data considerations

  • Use a background context for heavy work (NSPrivateQueueConcurrencyType) and a main-queue context for UI.
  • Hop to main before touching NSManagedObject instances bound to the view context or updating fetched results controllers/SwiftUI.
  • Don’t mark Core Data stack types @MainActor unless they truly are UI-only abstractions.
do {
try backgroundContext.performAndWait {
// background operations
}
await MainActor.run {
try? viewContext.save()
// or update UI based on fetched objects in viewContext
}
} catch {
// handle error
}

Common compiler errors and how to resolve them

  • Error: “Call to main actor-isolated method ‘reloadData()’ in a synchronous nonisolated context”

    • Fix: Add await MainActor.run { tableView.reloadData() } or mark the enclosing function/type with @MainActor if it’s UI-facing.
  • Error: “Expression is not Sendable” or “Capture of non-Sendable type in @Sendable closure”

    • Fix: Conform value types to Sendable; avoid capturing reference types across actors; alternatively, run the closure on @MainActor if it’s UI-only.
  • Misuse: Task.detached used to update UI

    • Fix: Prefer Task { @MainActor in ... } for UI updates; Task.detached has no parent task and no actor inheritance, increasing risk of violations.
  • Missing await on main-actor APIs in Swift 6

    • Fix: Add await when calling @MainActor functions from non-main-actor contexts, or move that code into MainActor.run/Task { @MainActor in }.

Checklist before shipping

  • Build with swift 6 strict concurrency checks (or Swift 5 Complete checking) enabled
  • Run with Main Thread Checker; ensure zero warnings on critical flows
  • View models that mutate UI state are @MainActor
  • Services and data layers are not @MainActor and are Sendable where appropriate
  • UI updates from background work use either await MainActor.run or Task { @MainActor in } as appropriate
  • Avoid Task.detached for UI paths
  • CI treats concurrency warnings as errors

Key takeaways

  • An @MainActor violation means you’re touching main-actor-isolated APIs from the wrong context. In Swift 6, these become hard errors.
  • Combine compile-time safety (swift 6 strict concurrency checks) with runtime safety (Main Thread Checker) to catch issues early.
  • Isolate UI-bound types with @MainActor, keep heavy work off the main actor, and hop explicitly with MainActor.run or Task { @MainActor in }.
  • Understanding mainactor.run vs task helps you choose the right tool for deterministic updates vs fire-and-forget UI actions.
  • With these patterns, you eliminate @MainActor violations and ship smoother, safer apps.

Next steps:

  • Audit your UI pathways for main-actor isolation
  • Enable strict concurrency checking in all targets (including tests)
  • Incrementally annotate and refactor; let the compiler guide you to a safer architecture

This approach will keep your code aligned with Apple’s current recommendations, avoid “call to main actor-isolated method” build errors, and prevent Main Thread Checker warnings from slipping into production.

Exploring Swift Concurrency to Resolve Deadlocks in iOS Apps

Published: · 5 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

Deadlocks are among the most notorious bugs in iOS development, often surfacing under unpredictable load or in obscure device scenarios. These elusive issues can wreak havoc on user experience, threaten app reliability, and complicate debugging efforts-especially as apps increase in complexity and concurrency. Fortunately, with Swift Concurrency, Apple has equipped us with robust tools to mitigate deadlocks, streamline asynchronous code, and supercharge both debugging and observability.

This post aims to unwrap how Swift Concurrency helps resolve deadlocks in iOS apps while focusing on real-world engineering challenges: performance optimization, effective debugging, boosting observability, and ultimately ensuring application reliability. Whether you’re a mobile developer, QA engineer, or engineering leader, you’ll find practical strategies and actionable insights to fortify your apps against concurrency woes.

Understanding Deadlocks in Legacy and Modern iOS Apps

What Is a Deadlock, Really?

A deadlock occurs when two or more operations wait indefinitely for each other to release a resource-say, a database lock, a semaphore, or a thread. In pre-Swift Concurrency architectures (using GCD or NSOperationQueues), subtle mismanagement of queues or locks often led to deadlocks, especially where main-thread and background-thread boundaries blurred.

Classic Example with GCD:

DispatchQueue.main.async {
DispatchQueue.main.sync {
// This will never execute.
}
}

The code above causes a deadlock, as .sync tries to occupy the main queue, which is already busy running the outer .async closure.

Performance Pitfalls: Why Old Patterns Cause Deadlocks

Synchronous queues and misused locks are frequent culprits of performance bottlenecks and deadlocks:

  • Overutilization of Serial Queues: If a serial queue is blocked by a long-running operation or a synchronous call, tasks pile up.
  • Escalating Lock Hierarchies: Complex nested locks can cause lock contention, increasing deadlock exposure.
  • Main Thread Choking: UI stuttering and freezes often happen when heavy computations or blocking calls occur on the main thread.

Swift Concurrency to the Rescue

Swift Concurrency introduces structured concurrency using async/await, actors, and tasks-alleviating most deadlock sources by design:

  • Elimination of Manual Queue Management: Replaces ad-hoc dispatches and queue juggling with higher-level, structured abstractions.
  • Actors Provide Data Isolation: Prevents data races and deadlocks through actor isolation.
  • Task Suspension vs. Blocking: Tasks are suspended (await) rather than blocked, so the underlying thread remains available to service other tasks.

Deadlock-Free Example with Actors:

actor Account {
private var balance: Int = 0

func deposit(_ amount: Int) {
balance += amount
}

func withdraw(_ amount: Int) -> Bool {
guard balance >= amount else { return false }
balance -= amount
return true
}
}

let account = Account()
await account.deposit(100)
await account.withdraw(50)

Here, Account’s state is protected by the actor; only one task accesses its methods at a time, sidestepping manual locks (and hence, deadlocks).

Debugging Deadlocks: Strategies with Swift Concurrency

Even with modern concurrency, bugs can (and do) happen-often as race conditions, actor reentrancy issues, or misconfigured priorities.

Actionable Debugging Strategies

  1. Use XCTest’s measure and expectation APIs:

    func testAsyncFlow() async {
    let expectation = XCTestExpectation(description: "Actor completes work")
    Task {
    await account.deposit(100)
    expectation.fulfill()
    }
    await fulfillment(of: [expectation], timeout: 1.0)
    }

    Stress-test async code to surface timing issues.

  2. Leverage Swift’s Concurrency Debugging Tools:

    • Thread Sanitizer (TSan): Detects data races and thread misuse.
    • Appxiom (or similar observability tools): Helps monitor runtime behavior, task execution, and performance both in testing and real-world scenarios.
    • Concurrency Runtime Debugging Flags: Set -Xfrontend -enable-actor-data-race-checks to aggressively catch actor reentrancy issues.
  3. Break Down Async Chains:

    • Isolate suspicious async flows into replicable, single-responsibility tasks to root-cause misbehaviors.
  4. Observe Task Scheduling:

    • Track active tasks and pending work with custom logging wrappers.
    • Use Instruments’ Concurrency Trace tool to visualize actor hops and inter-task dependencies.

Embedding Observability into Concurrency

Robust observability helps you see deadlocks before users experience them.

Practical Observability Patterns

  • Add Distributed Tracing:

    • Annotate business-critical async flows with custom spans or ID tags.
    • Integrate tracing to backend spans using open protocols (e.g., OpenTelemetry).
  • Custom Task Monitoring:

    struct ObservedTask {
    static func run(_ name: String, block: @escaping () async -> Void) {
    Task {
    print("[\(Date())] Starting \(name)")
    await block()
    print("[\(Date())] Finished \(name)")
    }
    }
    }

    ObservedTask.run("DepositOp") {
    await account.deposit(100)
    }
    • Enables fine-grained time and sequence tracking of your async operations.
  • Error and Timeout Reporting:

    • Implement async function timeouts and propagate failures to central logging for real-time alerting.

Reliability at Scale: Real-World Integration Patterns

When architecting for reliability:

  • Replace Shared Mutable State with Actors:

    • Model domain entities (accounts, caches, API clients) as actors.
    • This avoids data races and deadlocks from shared state.
  • Adopt Structured Concurrency Hierarchies:

    • Group related tasks under parent TaskGroup for automatic cancellation and resource management.
    await withTaskGroup(of: Void.self) { group in
    for i in 1...10 {
    group.addTask {
    await account.deposit(i * 10)
    }
    }
    }
  • Graceful Degradation:

    • Design async flows to degrade gracefully under overload (e.g., with fallback mechanisms or circuit breakers embedded in actors).
  • Comprehensive Testing (Stress, Soak, and Chaos):

    • Run simulated high-concurrency loads in CI, using both Xcode’s UI and custom in-app concurrency test suites.

Key Takeaways and Next Steps

Embracing Swift Concurrency is more than just keeping up with API trends-it’s a critical evolution for building apps that can scale, without fragile deadlock-prone architectures. By replacing manual queue management with actors and async/await, you mitigate many classic multithreading bugs. Enhanced debugging tools, observability patterns, and stress-testing further future-proof your app against reliability pitfalls. Platforms like Appxiom can further strengthen this by providing real-time observability into async flows, task execution, and performance behavior in production-helping you catch issues that traditional debugging tools might miss.

As a next step:

  • Audit your codebase for legacy queue or locking patterns; refactor to actors and async functions where possible.
  • Embed observability and concurrency-aware diagnostics into your builds.
  • Share knowledge across dev and QA teams to level up your concurrency practices.

Swift Concurrency is your ally-not only to resolve deadlocks, but to unlock a new tier of performance, debug-ability, and user trust in your iOS app. Start taming those threads-before your users (or crash logs) demand it!