DEV Community

ArshTechPro
ArshTechPro

Posted on

Safely Migrating Singletons to Swift 6

If you're migrating your iOS project to Swift 6, you've probably encountered this dreaded warning:

Static property 'shared' is not concurrency-safe because it is 
non-isolated global shared mutable state; this is an error in Swift 6
Enter fullscreen mode Exit fullscreen mode

This article explains why this happens and provides practical, tested solutions for making your singletons and global instances concurrency-safe in Swift 6.

Understanding the Problem

Swift 6 introduces strict concurrency checking to eliminate data races at compile time. The compiler now flags any global mutable state that could be accessed from multiple threads without proper synchronization.

Consider this traditional singleton pattern:

class NetworkManager {
    static var shared = NetworkManager()
    private init() {}

    func fetchData() {
        // network logic
    }
}
Enter fullscreen mode Exit fullscreen mode

This code triggers Swift 6 warnings because:

  1. shared is a static var (mutable global state)
  2. Multiple threads can access it simultaneously
  3. The compiler cannot guarantee thread-safe access

Solution 1: Use static let with Sendable Conformance

When to use: Your singleton has immutable state or only contains thread-safe properties.

How it works: Making your instance immutable (static let) and conforming to Sendable tells the compiler it's safe to share across threads.
Note - use Sendable very carefully as you take the responsibility.

final class NetworkManager: Sendable {
    static let shared = NetworkManager()
    private init() {}

    func fetchData() async throws -> Data {
        // network logic
    }
}
Enter fullscreen mode Exit fullscreen mode

Key requirements:

  • Mark the class as final (required for class Sendable conformance)
  • Change static var to static let
  • Ensure all properties are immutable or thread-safe
  • Methods should be stateless or use only immutable data

Example with configuration:

final class APIProvider: Sendable {
    static let shared = APIProvider()

    let baseURL: URL  // Immutable - safe to share

    private init() {
        self.baseURL = URL(string: "https://api.example.com")!
    }
}
Enter fullscreen mode Exit fullscreen mode

Solution 2: Use @MainActor Isolation

When to use: Your singleton is primarily accessed from UI code or you want simplicity.

How it works: Isolating to @MainActor serializes all access to the main thread, making it inherently thread-safe.

@MainActor
final class ImageCache {
    static let shared = ImageCache()
    private var cache: [String: UIImage] = [:]

    private init() {}

    func image(forKey key: String) -> UIImage? {
        return cache[key]
    }

    func setImage(_ image: UIImage, forKey key: String) {
        cache[key] = image
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage from async contexts:

func loadImage() async {
    let cache = await ImageCache.shared
    await cache.setImage(myImage, forKey: "profile")
}
Enter fullscreen mode Exit fullscreen mode

Performance considerations:

  • Accessing from the main actor context is synchronous (no performance penalty)
  • Accessing from other contexts requires an await (actor hop)
  • Despite common concerns, @MainActor is often the best choice for simplicity and correctness

When NOT to use:

  • Singletons accessed frequently from background threads
  • Performance-critical code that runs off the main thread

Solution 3: Use an Actor

When to use: Your singleton manages mutable state accessed from multiple contexts.

How it works: Actors automatically serialize access to their properties and methods.

actor DatabaseManager {
    static let shared = DatabaseManager()

    private var cache: [String: Any] = [:]

    private init() {}

    func save(_ value: Any, forKey key: String) {
        cache[key] = value
    }

    func retrieve(forKey key: String) -> Any? {
        return cache[key]
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

func saveData() async {
    await DatabaseManager.shared.save(userData, forKey: "user")
}
Enter fullscreen mode Exit fullscreen mode

Trade-offs:

  • Pros: Automatic thread-safety, safe mutable state
  • Cons: All access must be async, can cause widespread code changes
  • Many developers report that actors can be "highly invasive" during migration

Solution 4: Use @unchecked Sendable with Manual Synchronization

When to use: You have existing thread-safe code using locks or dispatch queues.

How it works: You tell the compiler "trust me, I've handled synchronization manually."

final class Logger: @unchecked Sendable {
    static let shared = Logger()

    private var logs: [String: String] = [:]
    private let queue = DispatchQueue(label: "com.app.logger")

    private init() {}

    func setLog(message: String, key: String) {
        queue.sync {
            logs[key] = message
        }
    }

    func fetchLog(key: String) -> String? {
        queue.sync {
            return logs[key]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Critical warning: @unchecked Sendable bypasses the compiler's safety checks. You are responsible for ensuring thread safety. Use only when:

  • You have an existing, proven synchronization mechanism
  • The type wraps a thread-safe C/Objective-C library
  • You're temporarily migrating and will refactor later

Solution 5: Use nonisolated(unsafe) for Immutable-After-Init

When to use: Your singleton requires initialization but becomes immutable afterward.

How it works: Marks a property as outside the concurrency safety system.

struct APIConfig: Sendable {
    nonisolated(unsafe) static var shared: APIConfig!

    let apiKey: String
    let baseURL: URL

    static func configure(apiKey: String, baseURL: URL) {
        shared = APIConfig(apiKey: apiKey, baseURL: baseURL)
    }
}

// In app launch
APIConfig.configure(apiKey: "key", baseURL: url)
Enter fullscreen mode Exit fullscreen mode

Critical requirements:

  • Only call configure once during app launch
  • Never modify after initialization
  • Document clearly that this is not thread-safe

Warning: This is an escape hatch, not a solution. Use nonisolated(unsafe) only when refactoring isn't practical and you can guarantee safe usage.

Solution 6: Custom Global Actor

When to use: You want actor-like isolation but not on the main thread.

How it works: Create a custom global actor for a specific domain.

@globalActor
actor NetworkActor {
    static let shared = NetworkActor()
}

@NetworkActor
final class NetworkManager {
    static let shared = NetworkManager()
    private var cache: [URL: Data] = [:]

    private init() {}

    func cachedData(for url: URL) -> Data? {
        return cache[url]
    }
}
Enter fullscreen mode Exit fullscreen mode

Trade-offs: Similar to actors but with more control over the isolation domain. Can be more invasive than @MainActor.

Decision Tree: Which Solution Should You Use?

1. Is the singleton truly immutable?
   YES → Use static let + Sendable (Solution 1)
   NO → Continue

2. Is it primarily accessed from UI code?
   YES → Use @MainActor (Solution 2)
   NO → Continue

3. Can you tolerate async access everywhere?
   YES → Use Actor (Solution 3)
   NO → Continue

4. Do you already have thread-safe synchronization?
   YES → Use @unchecked Sendable (Solution 4)
   NO → Continue

5. Is it immutable after app launch?
   YES → Use nonisolated(unsafe) (Solution 5)
   NO → Reconsider your architecture
Enter fullscreen mode Exit fullscreen mode

Common Patterns and Examples

Settings Manager with @MainActor

@MainActor
final class SettingsManager: ObservableObject {
    static let shared = SettingsManager()

    @Published var isDarkMode: Bool = false
    @Published var notificationsEnabled: Bool = true

    private init() {}
}

// Usage in SwiftUI
struct ContentView: View {
    @ObservedObject var settings = SettingsManager.shared

    var body: some View {
        Toggle("Dark Mode", isOn: $settings.isDarkMode)
    }
}
Enter fullscreen mode Exit fullscreen mode

Network Manager with Actor

actor NetworkManager {
    static let shared = NetworkManager()

    private var sessionConfig: URLSessionConfiguration
    private var activeRequests: Set<UUID> = []

    private init() {
        self.sessionConfig = .default
    }

    func makeRequest(url: URL) async throws -> Data {
        let requestId = UUID()
        activeRequests.insert(requestId)
        defer { activeRequests.remove(requestId) }

        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }

    func cancelAllRequests() {
        activeRequests.removeAll()
    }
}
Enter fullscreen mode Exit fullscreen mode

Analytics Manager with @unchecked Sendable

final class AnalyticsManager: @unchecked Sendable {
    static let shared = AnalyticsManager()

    private var events: [String] = []
    private let lock = NSLock()

    private init() {}

    func trackEvent(_ event: String) {
        lock.lock()
        defer { lock.unlock() }
        events.append(event)
    }

    func getEvents() -> [String] {
        lock.lock()
        defer { lock.unlock() }
        return events
    }
}
Enter fullscreen mode Exit fullscreen mode

Migration Strategy

  1. Enable Strict Concurrency Checking
   Build Settings → Swift Compiler → Concurrency Checking → Complete
Enter fullscreen mode Exit fullscreen mode
  1. Start with the Simplest Cases

    • Begin with singletons that can be made immutable (static let + Sendable)
    • These require the least refactoring
  2. Use @MainActor for UI-Related Singletons

    • Quick wins with minimal code changes
    • Works well with SwiftUI and UIKit
  3. Audit Performance After Migration

    • Profile your app to identify any performance regressions
    • Consider custom actors for hot paths
  4. Avoid @unchecked Sendable Unless Necessary

    • Use it as a last resort
    • Document why it's needed
    • Plan to refactor it away

Common Pitfalls

Pitfall 1: Using @MainActor Synchronously in Non-Main Contexts

// ❌ WRONG - This might crash or behave unexpectedly
DispatchQueue.global().async {
    let manager = MyMainActorSingleton.shared // Synchronous access
}

// ✅ CORRECT
Task {
    let manager = await MyMainActorSingleton.shared
}
Enter fullscreen mode Exit fullscreen mode

Note: Swift 6 language mode catches this at compile time, but in Swift 5 mode with concurrency checking, it might not.

Pitfall 2: Over-Using Actors

Making every singleton an actor can lead to "actor infection" where async spreads throughout your codebase. Consider whether @MainActor or proper Sendable conformance might work better.

Pitfall 3: Misunderstanding nonisolated(unsafe)

// ❌ WRONG - This is NOT thread-safe
class Config {
    nonisolated(unsafe) static var shared = Config()
    var apiKey: String = "" // Mutable!
}

// Concurrent access can cause data races
Config.shared.apiKey = "key1" // Thread 1
Config.shared.apiKey = "key2" // Thread 2
Enter fullscreen mode Exit fullscreen mode

nonisolated(unsafe) doesn't make your code safe—it just tells the compiler to stop checking.

Testing Concurrency Safety

Use Thread Sanitizer to catch runtime concurrency issues:

  1. Edit Scheme → Run → Diagnostics
  2. Enable "Thread Sanitizer"
  3. Run your tests

Thread Sanitizer will detect data races that the compiler might miss, especially in @unchecked Sendable or nonisolated(unsafe) code.

Key Takeaways

  1. Swift 6 requires explicit concurrency safety for all global state
  2. Prefer static let + Sendable when possible—it's the safest and most efficient
  3. @MainActor is underrated—it's often the best balance of simplicity and safety
  4. Actors are powerful but invasive—use them when you truly need isolated mutable state
  5. Escape hatches exist but use them sparingly@unchecked Sendable and nonisolated(unsafe) should be temporary
  6. Migration takes time—don't try to fix everything at once

Additional Resources

Conclusion

Migrating singletons to Swift 6 can feel overwhelming, but it's an opportunity to make your code safer and more maintainable. Start with the low-hanging fruit (immutable singletons), use @MainActor liberally for UI-related code, and only reach for more complex solutions when needed.


This article was written based on official Swift documentation, community discussions, and real-world migration experiences.

Top comments (0)