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 set block and make wrappedValue a get‑only property. The lock still protects the one‑time initialisation.

Edge cases to watch

  1. Recursive access – if the initializer itself reads the same property you’ll deadlock. Keep initialisers pure.
  2. 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 @MainActor isolation if you need a single shared instance.
  3. PerformanceNSLock is fast, but for hot paths consider os_unfair_lock or the new Mutex from 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.