Skip to content

Thread Safety with Actors (Swift)

A SwiftUI guide for coding agents. Also covers actor keyword, actor isolation, actor reentrancy race, await actor property, task caching dedupe, actors vs locks.

An actor is a reference type whose stored properties the compiler protects from concurrent access. Declaring actor instead of class replaces every hand-placed lock, and thread safety becomes compile-time checked.

From Lock to Actor

Locks fail silently when you forget one withLock, and they block the calling thread. An actor needs neither: change the keyword and delete the lock.

@dynamicMemberLookup actor Store<State, Action> {
    private var state: State
    private let reduce: (State, Action) -> State

    init(state: State, reduce: @escaping (State, Action) -> State) {
        self.state = state
        self.reduce = reduce
    }

    subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
        state[keyPath: keyPath]
    }

    func send(_ action: Action) {
        state = reduce(state, action)
    }
}

Every access from outside needs await, because another task may hold the actor.

await withTaskGroup(of: Void.self) { group in
    for _ in 0..<1_000_000 {
        group.addTask { await store.send(()) }
    }
}
let value = await store.value

Reentrancy Is the Trap

When actor code awaits something async, the actor suspends and lets other tasks run its isolated code in the meantime. Every await inside an actor is a possible interleaving point, so an actor is not atomic across a suspension.

actor ImageCache {
    private var cache: [URL: Data] = [:]

    func get(_ url: URL) async throws -> Data? {
        if let data = cache[url] { return data }
        let data = await download(url)   // second caller can enter here
        cache[url] = data
        return data
    }
}

Two callers asking for the same URL both miss the cache and both download. The fix is to store the in-flight Task itself, so the check and the reservation happen with no await between them.

actor ImageCache {
    private var cache: [URL: Task<Data, Never>] = [:]

    func get(_ url: URL) async throws -> Data? {
        if let task = cache[url] { return await task.value }
        let task = Task { await download(url) }
        cache[url] = task
        return await task.value
    }
}

Default to actors; reach for locks only when you need thread safety outside an async context.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-thread-safety-in-swift-with-actors" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems