Making Weak References Thread-Safe in Swift with a Tiny Macro
If you’ve worked with delegates in Swift, you’ve almost certainly used weak
references. They’re the go-to way to avoid retain cycles—but they come with a
subtle catch: they’re not thread-safe.
Now, in a lot of cases, that’s fine. If you only ever touch your delegate on the
main thread, you’re safe. But what if you want to make your class Sendable
and
use it across multiple threads in Swift concurrency land? Suddenly, things get messy.
The problem
Here’s a simple example:
import Foundation
final class APINetwork: Sendable {
// Stored property 'delegate' of 'Sendable'-conforming class 'APINetwork'
// is mutable; this is an error in the Swift 6 language mode
weak var delegate: URLSessionDelegate?
}
Even though URLSessionDelegate itself conforms to Sendable
, Swift yells at you.
Why? Because that weak
property is mutable and not thread-safe.
At this point, you might be tempted to slap @MainActor
on the class
and call it a day.
But that’s overkill—especially if your class doesn’t really need to be main-actor isolated.
Worse, it forces every call site to also hop to the main actor. Not ideal.
The solution
That’s where Locked Weak Reference comes in. It’s a tiny package I built that lets you wrap all
your weak properties in a thread-safe container using a macro. Here’s what your code looks like
with it:
import Foundation
import LockedWeakReference
@LockedWeakReference
final class APINetwork: Sendable {
weak var delegate: URLSessionDelegate?
}
That’s it. No weird boilerplate. No custom locks sprinkled all over your code.
The macro finds all weak properties and wraps them in a safe, atomic type under
the hood.
If you peek at the expansion, you’ll see it’s doing the lock dance for you—so
your weak reference is safe to read and write from multiple threads:
import Foundation
import LockedWeakReference
@LockedWeakReference
final class APINetwork: Sendable {
@RegisterWeakReference
weak var delegate: URLSessionDelegate?
{
get {
_delegate.withLock {
$0 as? URLSessionDelegate
}
}
set {
_delegate.withLock {
$0 = newValue
}
}
}
private let _delegate = LockedWeakReference()
}
private extension APINetwork {
final class LockedWeakReference: @unchecked Sendable {
private let lock: NSLock = {
NSLock()
}()
private(set) weak var value: AnyObject?
func withLock<R>(_ body: (inout AnyObject?) throws -> R) rethrows -> R {
lock.lock()
defer {
lock.unlock()
}
return try body(&value)
}
}
}
Why this matters
The key benefit here is you can now confidently mark your classes as Sendable
without having to mark them @MainActor unnecessarily. That means you avoid
cascading main actor constraints to call sites that really don’t need them.
It’s one of those small utilities that scratches a very specific itch—but if
you’ve hit this problem, you know how annoying it can be.
Check out the repo here: Locked Weak Reference on GitHub.