For fifteen years, this was the most copy-pasted pattern in iOS development:
class ImageCache {
static let shared = ImageCache()
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func store(_ image: UIImage, for url: URL) {
cache[url] = image
}
}
Turn on Swift 6 language mode, and the compiler has news for you:
Static property 'shared' is not concurrency-safe because non-'Sendable' type 'ImageCache' may have shared mutable state
The compiler isn't being pedantic. It's telling you something that was true all along: if a background task calls store while the main thread calls image(for:), that dictionary can be read and written simultaneously. That's a data race. Undefined behaviour, and the source of those crashes that only ever reproduce in production, under load, on a Tuesday.
Swift 6 made data race safety a compile-time guarantee rather than a suggestion (Apple's migration guide covers the full family of errors). So the singleton pattern didn't break. It got caught.
Here are your four ways out, roughly in order of how often I'd reach for them.
Fix 1: Make It an Actor
If your singleton exists to protect mutable state, an actor is the tool designed for exactly that job:
actor ImageCache {
static let shared = ImageCache()
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func store(_ image: UIImage, for url: URL) {
cache[url] = image
}
}
One keyword changed. The actor serialises all access to cache, so races are impossible by construction. The cost shows up at the call site, where everything becomes asynchronous:
let image = await ImageCache.shared.image(for: url)
That await is honest: another task might genuinely be mid-write, and you might genuinely wait. If sprinkling await through your code feels like a burden, that's the pattern's real cost finally appearing on the label.
Fix 2: Pin It to the Main Actor
Lots of "singletons" in iOS apps are really just app-level state that's only ever touched from the UI. If that's your situation, say so:
@MainActor
final class AppState {
static let shared = AppState()
var currentUser: User?
}
Now the compiler enforces that all access happens on the main actor. From SwiftUI views and other @MainActor code, there's no await and no friction. It reads exactly like the old code, except now it's a checked guarantee instead of a hopeful convention. Donny Wals's guide to singletons in Swift 6 is a good deep dive on choosing between this and a full actor.
And if you adopt Swift 6.2's "approachable concurrency" direction, where a module can default to @MainActor isolation, most app code gets this behaviour without the annotation. Single-threaded by default; concurrency where you opt in.
Fix 3: Make It Genuinely Immutable
Sometimes the fix is realising your singleton never needed mutable state at all:
final class Analytics: Sendable {
static let shared = Analytics()
private let endpoint = URL(string: "https://api.example.com/events")!
func log(_ event: String) {
// fire off a network request; no shared state mutated
}
}
A final class with only let properties (of Sendable types) can safely conform to Sendable, and the error disappears. No actor, no await, no locks. If you can get here, this is the nicest place to be.
Fix 4: A Mutex, for the Hot Path
Occasionally you need synchronous access and mutation. An actor's await won't fit, and @MainActor is wrong because background threads legitimately call it. iOS 18's Synchronization framework gives you a proper mutex:
import Synchronization
final class RequestCounter: Sendable {
static let shared = RequestCounter()
private let count = Mutex(0)
func increment() -> Int {
count.withLock { count in
count += 1
return count
}
}
}
Mutex is Sendable, and withLock makes it hard to forget to unlock. This is the right tool for small, performance-sensitive bits of state, and the wrong tool for your app's general architecture. If you find yourself with five mutexes, you wanted an actor.
The One You'll Be Tempted By
nonisolated(unsafe) static let shared = ImageCache() // please don't
nonisolated(unsafe) tells the compiler "trust me". It silences the error and checks nothing. The race is still there; you've just deleted the warning about it. It exists as a migration escape hatch for code you can prove is safe by some other means (say, a type protected by a legacy DispatchQueue). As a fix, it's a comment that says "this crashes sometimes" written in syntax.
The Taste Test
When the compiler flags your singleton, ask yourself:
- Is the state only touched from the UI? Use
@MainActor(Fix 2). - Is there actually any mutable state? If not, add
Sendableconformance (Fix 3). - Is it genuinely shared, mutable, and accessed from anywhere? Make it an actor (Fix 1).
- Is it a tiny, hot, synchronous counter or cache? Use
Mutex(Fix 4). - Are you reaching for
nonisolated(unsafe)? Go back to 1.
Wrapping Up
Swift 6 didn't break your singleton. It revealed it. Every one of these compiler errors marks a place where your app was one unlucky thread schedule away from corrupt state, and every fix above replaces a hopeful convention with a guarantee.
The pattern that survives isn't "global mutable object everyone pokes at". It's "shared state with a clearly declared home". Give your state a home, and the compiler goes from adversary to bodyguard.