Thread‑Safe Lazy Initialization in Swift with a Custom Property Wrapper
Why lazy isn’t always enough
Swift’s built‑in lazy keyword works great for single‑threaded code, but the moment you touch a property from multiple queues you race to initialise it twice — or worse, read a half‑initialised value. I hit this exact issue while building a networking layer that shares a URLSession configuration across background download tasks and the main UI thread. The fix? A tiny property wrapper that guarantees thread‑safe, one‑time initialisation without sprinkling locks everywhere.
The problem in the wild
Imagine a CacheManager that holds a decoded JSON model. The model is expensive to parse, so you want it created on first access. In a single‑threaded app lazy var model = parse() is fine. Add a background refresh that also reads model while the UI might be displaying it, and you get two parses or a crash when the UI reads a partially‑filled struct. I’ve seen this bite teams during app‑launch optimisation sprints.
Building a reusable wrapper
The wrapper below uses an NSLock (lightweight, re‑entrant safe) and stores the value once it’s created. The initializer is captured as an autoclosure so callers keep the familiar = MyExpensiveThing() syntax.
@propertyWrapper
public struct ThreadSafeLazy {
private let lock = NSLock()
private var storage: Value?
private let initializer: () -> Value
public init(wrappedValue: @autoclosure @escaping () -> Value) {
self.initializer = wrappedValue
}
public var wrappedValue: Value {
mutating get {
lock.lock()
defer { lock.unlock() }
if storage == nil {
storage = initializer()
}
return storage!
}
set {
lock.lock()
defer { lock.unlock() }
storage = newValue
}
}
}
Using it in production code
Drop the wrapper into any type — struct, class, or even a global — and the call site looks exactly like a normal lazy property.
final class ImageDownloader {
@ThreadSafeLazy var sharedSession: URLSession = {
let config = URLSessionConfiguration.default
config.requestCachePolicy = .returnCacheDataElseLoad
return URLSession(configuration: config)
}()
func fetch(_ url: URL) async throws -> Data {
let (data, _) = try await sharedSession.data(from: url)
return data
}
}
Now sharedSession is created the first time any thread calls fetch, and subsequent calls — even from the main thread — reuse the same instance without a data race.
Why this works better than a global let
- Deferred creation – the session isn’t built at app launch, saving launch‑time work.
- Explicit ownership – the wrapper lives on the owning type, so you can have multiple independent lazy values with different lifecycles.
- Testability – you can swap the initializer in unit tests by injecting a mock closure.
Tip: If you need a read‑only lazy value, drop the
setblock and makewrappedValueaget‑only property. The lock still protects the one‑time initialisation.
Edge cases to watch
- Recursive access – if the initializer itself reads the same property you’ll deadlock. Keep initialisers pure.
- Value semantics – the wrapper is a struct, so copying the owning type copies the lock and storage. Mark the owner as a class or use
@MainActorisolation if you need a single shared instance. - Performance –
NSLockis fast, but for hot paths consideros_unfair_lockor the newMutexfrom Swift 5.9.
Wrapping up
A five‑line property wrapper saves you from sprinkling DispatchQueue barriers or @synchronized across the codebase. I keep a ThreadSafeLazy file in my shared utilities module and reach for it whenever a value is expensive, shared, and initialised on demand. It’s a small abstraction that pays off every time a new background task touches the same resource.