Advanced Concurrency

@unchecked Sendable and Thread Safety

August 23, 2026
5 min read
Featured image for blog post: @unchecked Sendable and Thread Safety

Sometimes you need a reference type to be Sendable, but the compiler can't verify it. That's where @unchecked Sendable comes in.

Follow along with the code: iOS-Practice on GitHub

What @unchecked Means

You're telling the compiler: "Trust me, I've made this thread-safe manually."

final class ThreadSafeCounter: @unchecked Sendable {
    private var _count = 0
    private let lock = NSLock()

    var count: Int {
        lock.lock()
        defer { lock.unlock() }
        return _count
    }

    func increment() {
        lock.lock()
        defer { lock.unlock() }
        _count += 1
    }
}

The compiler allows this across actor boundaries because YOU promised it's safe.

When to Use @unchecked Sendable

1. Lock-based synchronization:

final class SynchronizedArray<T>: @unchecked Sendable {
    private var array: [T] = []
    private let lock = NSLock()

    func append(_ item: T) {
        lock.lock()
        defer { lock.unlock() }
        array.append(item)
    }

    func getAll() -> [T] {
        lock.lock()
        defer { lock.unlock() }
        return array
    }
}

2. Dispatch queue synchronization:

final class QueueSafeStorage: @unchecked Sendable {
    private var data: [String: Any] = [:]
    private let queue = DispatchQueue(label: "storage", attributes: .concurrent)

    func read(_ key: String) -> Any? {
        queue.sync { data[key] }
    }

    func write(_ key: String, value: Any) {
        queue.async(flags: .barrier) { self.data[key] = value }
    }
}

3. Wrapping non-Sendable system types:

final class SendableDateFormatter: @unchecked Sendable {
    private let formatter: DateFormatter
    private let lock = NSLock()

    init(format: String) {
        formatter = DateFormatter()
        formatter.dateFormat = format
    }

    func string(from date: Date) -> String {
        lock.lock()
        defer { lock.unlock() }
        return formatter.string(from: date)
    }
}

The Dangers

@unchecked disables compiler checks. If you're wrong, you get data races:

// DANGEROUS: No actual synchronization!
final class BrokenCache: @unchecked Sendable {
    private var cache: [String: Data] = [:]  // No lock!

    func get(_ key: String) -> Data? {
        cache[key]  // Race condition!
    }

    func set(_ key: String, data: Data) {
        cache[key] = data  // Race condition!
    }
}

This will compile but crash randomly at runtime.

Alternatives to @unchecked

Use an actor instead:

actor SafeCache {
    private var cache: [String: Data] = [:]

    func get(_ key: String) -> Data? {
        cache[key]
    }

    func set(_ key: String, data: Data) {
        cache[key] = data
    }
}

Actors provide synchronization automatically—safer than manual locks.

Use a Sendable struct:

struct CacheEntry: Sendable {
    let key: String
    let data: Data
    let timestamp: Date
}

Value types copy, eliminating shared mutable state.

Auditing @unchecked Sendable

When reviewing code, treat @unchecked Sendable as a red flag:

  1. Verify ALL mutable state is protected
  2. Check that ALL access paths use synchronization
  3. Consider if an actor would be simpler

Interview Tip

Show you understand the trade-off: "@unchecked Sendable is an escape hatch when you need manual synchronization, but actors are usually safer. I only use @unchecked for legacy code or when wrapping thread-unsafe system types."

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch