Skip to main content

Swift 6 Migration: Fixing 'Static property shared is not concurrency-safe'

 The transition to Swift 6 is one of the most significant shifts in the language's history. By enabling "Strict Concurrency Checking," the compiler moves thread-safety analysis from runtime to compile-time. While this eliminates an entire class of Heisenbugs and race conditions, it also turns legacy codebases into a sea of red warnings.

The most common error developers encounter during this migration targets the Singleton pattern:

"Static property 'shared' is not concurrency-safe because it is not thread-safe."

If you are seeing this error, the compiler has identified a potential data race in your global state. This article explains why the Singleton pattern violates Swift 6 strict concurrency rules and provides three architectural patterns to fix it definitively.

The Root Cause: Global Mutable State

To understand the fix, you must understand the new rules of the road. In Swift 6, global variables (including static properties like shared) must be isolated.

In previous Swift versions, the following code compiled without complaint, even though it is fundamentally unsafe in a multithreaded environment:

class UserSession {
    static let shared = UserSession() // ❌ Error in Swift 6
    var activeToken: String?
    
    private init() {}
}

Why the Compiler Rejects This

The static let shared property is effectively a global variable. It can be accessed from any thread (Main Thread, Background Queue, etc.) simultaneously.

  1. Mutability: The UserSession class is a reference type (class) containing mutable state (var activeToken).
  2. Lack of Synchronization: Standard classes do not protect their properties from concurrent read/write operations.
  3. The Race Condition: If Thread A reads activeToken while Thread B writes to it, the application will crash or behave unpredictably.

Swift 6 requires that any type assigned to a global or static variable conforms to Sendable. A standard class with mutable properties is not Sendable because it cannot guarantee safe concurrent access.

Solution 1: The @MainActor Approach (UI-Bound State)

If your singleton primarily manages UI state, navigation logic, or data that is only ever displayed in Views, the simplest fix is to isolate the singleton to the MainActor.

This tells the compiler: "Only allow access to this shared instance from the main thread." Since all access is serialized on a single thread, data races are impossible.

Implementation

Annotate both the class and the static property with @MainActor.

@MainActor
final class UIManager {
    // 1. Isolate the property
    static let shared = UIManager()
    
    var currentTheme: String = "Dark"
    
    private init() {}
    
    func updateTheme(to newTheme: String) {
        self.currentTheme = newTheme
    }
}

Usability Changes

Because shared is now isolated to the Main Actor, accessing it from a background context requires await.

// From a SwiftUI View (implicitly MainActor)
Text(UIManager.shared.currentTheme) // ✅ Works synchronously

// From a background operation
func fetchSettings() async {
    // ❌ Error: Call to main-actor-isolated property 'shared' in a non-isolated context
    // let theme = UIManager.shared.currentTheme 
    
    // ✅ Fix: Await the jump to the main thread
    let theme = await UIManager.shared.currentTheme
}

Solution 2: The actor Approach (Background Logic)

For singletons that manage database connections, caching, or networking, forcing execution onto the Main Thread is a performance bottleneck. The correct modern solution is to convert the class to an actor.

Actors are reference types that automatically serialize access to their mutable state. They provide a lock-free mechanism to ensure thread safety.

Implementation

Change class to actor. No strict Sendable warnings will occur because actors are implicitly Sendable.

actor NetworkManager {
    static let shared = NetworkManager()
    
    private var activeRequests: [String] = []
    
    private init() {}
    
    func trackRequest(_ id: String) {
        activeRequests.append(id)
    }
    
    func clearRequest(_ id: String) {
        if let index = activeRequests.firstIndex(of: id) {
            activeRequests.remove(at: index)
        }
    }
}

The Trade-off

Interaction with an actor is asynchronous. You must await any method call or property access from outside the actor.

func startDownload() async {
    // Automatically hops to the actor's executor to run safely
    await NetworkManager.shared.trackRequest("Image-123")
}

Solution 3: The nonisolated(unsafe) Escape Hatch (Legacy Code)

There are scenarios where you cannot convert a class to an actor or push it to the main thread immediately—perhaps due to massive refactoring costs or internal locking mechanisms (like NSLock or a private DispatchQueue) that the Swift compiler cannot "see."

If you have verified that your class is thread-safe using internal synchronization, you can disable the compiler check for specific properties.

Implementation

Use nonisolated(unsafe) to tell the compiler you are taking responsibility for memory safety.

final class LegacyAnalytics {
    // ⚠️ USE WITH CAUTION: You are overriding the safety check.
    nonisolated(unsafe) static let shared = LegacyAnalytics()
    
    private let lock = NSLock()
    private var events: [String] = []
    
    private init() {}
    
    func log(_ event: String) {
        lock.lock()
        defer { lock.unlock() }
        events.append(event)
    }
}

Warning: Do not use this just to silence the error. If you use nonisolated(unsafe) on a class that does not have internal locking, you are re-introducing the crashes Swift 6 tries to prevent.

Deep Dive: Immutable State and Sendable

Sometimes, your singleton is purely configuration data. If a class contains only immutable properties (constants), it is inherently thread-safe because no race conditions can occur on read-only data.

However, the compiler does not infer this automatically for classes. You must explicitly mark the class final and conform to Sendable.

final class AppConfig: Sendable {
    static let shared = AppConfig()
    
    let apiKey: String = "xc9-123-456"
    let baseURL: URL = URL(string: "https://api.example.com")!
    
    private init() {}
}

In this specific case:

  1. final: Ensures no subclasses can add mutable properties.
  2. let properties: Ensures state cannot change after initialization.
  3. Sendable: Confirms to the compiler that this type is safe to pass across concurrency domains.

With these traits, static let shared will compile without errors and requires no await or actor isolation.

Common Pitfalls

1. Actor Reentrancy

When migrating a Singleton to an actor, remember that actors are reentrant. If you await inside an actor method, the actor suspends. During that suspension, other threads can enter the actor and run other methods.

The Fix: Do not assume state remains consistent across an await call. Check your assumptions after the suspension.

2. Global Variable Initialization

Swift static let properties are lazily initialized. This initialization is thread-safe (uses dispatch_once under the hood). The concurrency error discussed in this article refers to the access of the variable after initialization, not the initialization itself.

Conclusion

The "Static property shared is not concurrency-safe" error is a safeguard, not a bug. It highlights areas where your app was previously susceptible to random crashes.

To fix it effectively in Swift 6:

  1. Use @MainActor for UI-related singletons (easiest migration).
  2. Use actor for high-performance background logic (most robust).
  3. Use final + Sendable for immutable configuration objects.
  4. Use nonisolated(unsafe) only when wrapping legacy code with internal locking.

By choosing the correct isolation strategy, you ensure your app is not only Swift 6 compliant but fundamentally more stable.