`lazy var` is not thread-safe, and `static let` is
Both of these defer work until first use:
class Service {
lazy var client = HTTPClient() // NOT thread-safe
}
enum Constants {
static let client = HTTPClient() // thread-safe
}
They look like the same feature at different scopes. They are implemented completely differently, and only one of them survives concurrent access.
What lazy var compiles to
Roughly this:
private var _client: HTTPClient?
var client: HTTPClient {
get {
if _client == nil {
_client = HTTPClient()
}
return _client!
}
}
There is no lock. Two threads reading client simultaneously can both see nil, both construct an
HTTPClient, and both write to _client. The best case is two objects created and one leaked; the
worst is a torn write to the optional and a crash.
It is also why lazy var cannot be let — the getter mutates, which is also why accessing a lazy var on a struct requires var and is unavailable from a non-mutating context.
What static let compiles to
Global and static properties are lazy in Swift too, but they use swift_once — the same mechanism as
dispatch_once. The initialiser is guaranteed to run exactly once, and every other thread blocks
until it finishes.
That is a real guarantee from the runtime, not an emergent property. static let is the correct way
to write a singleton in Swift, and it needs no dispatch_once boilerplate and no lock.
Warning
static var is lazy and once-initialised in the same way, but nothing protects later writes.
A mutable global is a data race by definition, and it is exactly what strict concurrency checking
flags first. static let is safe; static var needs an actor, a lock, or @MainActor.
When lazy var is fine
It is not a broken feature — it is a single-threaded one. It is correct when the owning object is confined to one thread, which covers a great deal of UI code:
@MainActor
final class ProfileViewController: UIViewController {
lazy var formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
}
@MainActor guarantees single-threaded access, so the lazy initialisation cannot race. That
annotation is doing real work here, and it is why the concurrency checker accepts this.
The other legitimate use is genuinely expensive setup that many instances will never need — a heavy parser, a large lookup table, a connection that most code paths do not touch.
The three alternatives
If it is shared and immutable — static let.
enum Formatters {
static let medium: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
}
Thread-safe, initialised once, no annotation needed.
If the type is main-thread-only — @MainActor and keep the lazy var.
If it is shared mutable state — an actor.
actor ImageCache {
private var storage: [URL: Image] = [:]
}
The thing that made this click
I had assumed lazy was a memory optimisation. It is not — it is a timing control. It says “run
this the first time somebody asks”, and the reason it exists is that the initialiser needs something
that is not available at init time, or is expensive enough that not everyone should pay for it.
Once I read it that way, the thread-safety question became obvious: a feature about when code runs says nothing about how many threads run it, and expecting it to be safe was me confusing two different problems.
The Swift Evolution proposal for lazy is explicit about this. The single-threaded restriction is
documented behaviour, not an oversight — making it thread-safe would have meant a lock on every
access to every lazy property, and the language chose to make you say so instead.