01 · INTRODUCTION
What Is a Logical Race in Swift Concurrency?
The short answer
A logical race occurs when concurrent operations are individually memory-safe, but the program's result depends on which valid operation happens to finish or commit first.
CODE EXAMPLE
@MainActor
final class LaunchViewModel {
private let api: LaunchAPI
private(set) var selectedLaunch: LaunchDetails?
init(api: LaunchAPI) {
self.api = api
}
func select(_ id: String) async throws {
let details = try await api.fetchDetails(for: id)
selectedLaunch = details
}
}
If the user selects launch A and then launch B, request A may finish after request B and replace the screen with the older selection. Every mutation runs safely on MainActor. The bug is not simultaneous memory access; it is that completion order has been allowed to decide application meaning.
The central rule
Data-race safety protects memory access. It does not decide which concurrent result your feature should accept.
02 · TUTORIAL
Why This Is Called a Logical Race
Logical race is an engineering term, not a separate Swift keyword, protocol or compiler diagnostic. It describes a race between valid operations whose ordering affects the program's intended behaviour.
Consider two user actions:
CODE EXAMPLE
1. User selects launch A
2. Request A begins
3. User selects launch B
4. Request B begins
5. Request B finishes
6. Request A finishes
7. Screen incorrectly displays A
Nothing in that sequence requires unsafe memory access. Both requests may be valid. Both results may be decoded correctly. Both state mutations may be actor-isolated. The feature is still wrong because the user's latest intent was B.
The operations are racing to influence a logical decision: which launch belongs on the screen.
03 · TUTORIAL
A Logical Race Is Different from a Data Race
A data race concerns unsynchronized overlapping access to the same memory, with at least one write. It can lead to undefined or unpredictable low-level behaviour.
A logical race can occur even when every memory access is synchronized correctly:
Question
What is racing?
Data race
Accesses to memory
Logical race
Valid operations or results
Question
Primary concern
Data race
Memory safety
Logical race
Application correctness
Question
Can actor isolation prevent it?
Data race
Yes, for isolated state
Logical race
Not by itself
Question
Typical symptom
Data race
Corruption, crashes or unsafe access
Logical race
Stale UI, duplicate work or wrong ordering
Question
Typical remedy
Data race
Isolation or synchronization
Logical race
An explicit feature-ordering policy
Important Terminology
A data race violates safe memory-access rules. A logical race violates a rule about which otherwise valid operation should affect the program.
04 · TUTORIAL
Actors Can Contain a Logical Race
The previous article showed that actors are reentrant. An actor serializes isolated portions of code, but another actor job may run when an async method suspends.
CODE EXAMPLE
actor LaunchStore {
private let api: LaunchAPI
private var selectedLaunch: LaunchDetails?
init(api: LaunchAPI) {
self.api = api
}
func select(_ id: String) async throws {
let details = try await api.fetchDetails(for: id)
selectedLaunch = details
}
}
Two calls can suspend on their requests. Whichever call resumes and assigns last determines selectedLaunch. The actor ensures those assignments are not simultaneous, but it has no built-in knowledge that the second user selection should outrank the first.
EXECUTION DIAGRAM
Actor-isolated commits
Request A starts ──────────────── finishes ── commits A
Request B starts ───── finishes ── commits B
Every commit is serialized.
The final value is still wrong.
Conceptual timeline: this is one permitted ordering, not a scheduling prediction. Correct code must work when either request completes first.
05 · TUTORIAL
Concurrency Exposes Missing Product Rules
Before fixing a logical race, define what the feature is supposed to mean. “Make it thread-safe” is not enough.
Different features need different policies:
• Latest request wins: a launch selection or search result should match the newest user intent.
• First successful result wins: redundant providers race to supply one usable response.
• All results are merged: every rocket provider contributes to an aggregate.
• Submission order is preserved: events must be applied in the order they were accepted.
• Only one operation may exist: duplicate refreshes should share or skip in-flight work.
These policies are not interchangeable. Cancelling an old request makes sense for latest-wins selection. It would be wrong for an aggregation feature that needs every result.
Do not begin with a synchronization primitive.
Begin by writing the rule that should hold when operations overlap.
06 · TUTORIAL
The Classic Latest-Request-Wins Bug
Here is a more complete UI model:
CODE EXAMPLE
@MainActor
final class LaunchDetailsModel {
enum State {
case idle
case loading(id: String)
case loaded(LaunchDetails)
case failed(id: String, message: String)
}
private let api: LaunchAPI
private(set) var state: State = .idle
init(api: LaunchAPI) {
self.api = api
}
func select(_ id: String) async {
state = .loading(id: id)
do {
let details = try await api.fetchDetails(for: id)
state = .loaded(details)
} catch {
state = .failed(id: id, message: String(describing: error))
}
}
}
The type is main-actor-isolated, so its state mutations are serialized. Yet an older success can overwrite a newer success, and an older failure can replace a newer loaded state.
The missing rule is:
CODE EXAMPLE
Only the result belonging to the most recent select(_:) call
may change the visible state.
Once stated this plainly, the implementation can encode it.
07 · TUTORIAL
Ways to Enforce the Feature's Ordering Policy
Solution 1: Give Every Operation an Identity
A monotonically increasing generation identifies which request is current:
CODE EXAMPLE
@MainActor
final class LaunchDetailsModel {
private let api: LaunchAPI
private var generation = 0
private(set) var state: State = .idle
func select(_ id: String) async {
generation += 1
let requestGeneration = generation
state = .loading(id: id)
do {
let details = try await api.fetchDetails(for: id)
guard requestGeneration == generation else {
return
}
state = .loaded(details)
} catch {
guard requestGeneration == generation else {
return
}
state = .failed(
id: id,
message: String(describing: error)
)
}
}
}
Each call captures its generation before suspending. After it resumes, it must prove that it is still current before committing either success or failure.
Comparing only the launch ID is weaker. Two refreshes for the same launch can still complete out of order. An operation identity distinguishes those requests even when their inputs are equal.
Solution 2: Cancel Superseded Work
A UI owner can retain the current unstructured task and cancel it before beginning another:
CODE EXAMPLE
@MainActor
final class LaunchSelectionController {
private let model: LaunchDetailsModel
private var selectionTask: Task<Void, Never>?
init(model: LaunchDetailsModel) {
self.model = model
}
func select(_ id: String) {
selectionTask?.cancel()
selectionTask = Task {
await model.select(id)
}
}
deinit {
selectionTask?.cancel()
}
}
Cancellation can save network, decoding or processing work when the operation and underlying APIs cooperate. It also communicates that the old selection is no longer useful.
Cancellation alone is not a correctness proof. Swift cancellation is cooperative. An operation may ignore the request, finish before observing it or return through an API that does not automatically throw on cancellation.
For a strict latest-wins rule, retain the generation check at the state-commit boundary even when old tasks are also cancelled.
Cancellation and Validation Solve Different Problems
Technique
Cancel the old task
Primary purpose
Ask obsolete work to stop early
Guarantee by itself
No stale commit guarantee
Technique
Check operation identity
Primary purpose
Reject an obsolete result
Guarantee by itself
Latest-wins commit rule
Technique
Use both
Primary purpose
Save work and enforce correctness
Guarantee by itself
Best fit for many UI selections
Cancellation is about the work. Validation is about permission to affect state.
Solution 3: Serialize Operations When Order Is the Feature
Some operations should not overlap at all. Suppose launch status events must be applied in submission order:
CODE EXAMPLE
actor LaunchEventProcessor {
private var events: [LaunchEvent] = []
func apply(_ event: LaunchEvent) {
events.append(event)
}
}
This synchronous actor method applies each accepted event as one isolated operation. However, if several independent tasks call apply(_:), do not assume their arrival order is the same as the order in which those tasks were created.
If source order matters, represent that order explicitly—for example, attach sequence numbers at ingestion and buffer until the next expected event is available. An actor serializes access; it does not manufacture missing ordering semantics.
Another design is to give one task ownership of consuming an AsyncSequence. That creates a single, visible point where events are applied in the sequence's delivery order.
08 · TUTORIAL
Task Groups Have Completion-Order Semantics
Task groups produce child results in completion order. That is useful, but completion order may not match the feature's required order:
CODE EXAMPLE
func loadLaunches(
ids: [String]
) async throws -> [LaunchDetails] {
try await withThrowingTaskGroup(
of: (Int, LaunchDetails).self
) { group in
for (index, id) in ids.enumerated() {
group.addTask {
let details = try await api.fetchDetails(for: id)
return (index, details)
}
}
var indexed: [(Int, LaunchDetails)] = []
for try await result in group {
indexed.append(result)
}
return indexed
.sorted { $0.0 < $1.0 }
.map(\.1)
}
}
The group is correct either way; it promises completion-order result delivery. The feature decides whether to retain that order, restore input order or use another ranking such as launch date.
A logical race appears when code accidentally treats incidental completion order as meaningful order.
09 · TUTORIAL
Actors, MainActor and Sendable Are Not Ordering Policies
Swift's safety tools answer important but different questions:
• Actor isolation: who may access this mutable state?
• MainActor: which global isolation domain owns this UI-related state?
• Sendable: can this value cross a concurrency boundary safely?
• Structured concurrency: which operation owns this child work and its lifetime?
None of these declarations means “the latest request wins” or “preserve submission order.” Those are feature semantics that application code must express.
CODE EXAMPLE
@MainActor
func display(_ result: LaunchDetails) {
selectedLaunch = result
}
This function safely updates main-actor state. It does not establish whether result is still relevant when the call arrives.
10 · TUTORIAL
Testing a Logical Race Without Timing Guesses
A test should control completion order rather than hope one request is slower:
CODE EXAMPLE
let taskA = Task { await model.select("A") }
await api.waitUntilRequested("A")
let taskB = Task { await model.select("B") }
await api.waitUntilRequested("B")
api.complete("B", with: detailsB)
api.complete("A", with: detailsA)
await taskA.value
await taskB.value
#expect(model.state == .loaded(detailsB))
This is conceptual test code; the controlled API can be implemented with safely managed continuations or an actor-backed signal mechanism. The test deliberately creates the adverse completion order and asserts the product rule.
A sleep-based test is weaker. Machine load can change its ordering, and passing once does not demonstrate that every valid schedule is handled.
11 · TUTORIAL
A Complete Latest-Wins Launch Feature
The finished model combines cooperative cancellation with commit validation:
CODE EXAMPLE
@MainActor
final class LaunchDetailsModel {
enum State: Equatable, Sendable {
case idle
case loading(id: String)
case loaded(LaunchDetails)
case failed(id: String, message: String)
}
private let api: LaunchAPI
private var selectionTask: Task<Void, Never>?
private var generation = 0
private(set) var state: State = .idle
init(api: LaunchAPI) {
self.api = api
}
func select(_ id: String) {
generation += 1
let requestGeneration = generation
selectionTask?.cancel()
state = .loading(id: id)
selectionTask = Task { [api] in
do {
let details = try await api.fetchDetails(for: id)
try Task.checkCancellation()
guard requestGeneration == generation else {
return
}
state = .loaded(details)
} catch is CancellationError {
// A replacement selection intentionally superseded this work.
} catch {
guard !Task.isCancelled else {
return
}
guard requestGeneration == generation else {
return
}
state = .failed(
id: id,
message: String(describing: error)
)
}
}
}
}
The task is main-actor-isolated because it is created in the model's main-actor context. After suspension, access to generation and state remains isolated.
The model now expresses two separate intentions:
• cancel work that is no longer useful;
• allow only the current generation to commit visible state.
Even if cancellation is not observed in time, the validation rule rejects the stale result.
12 · TUTORIAL
Common Misunderstandings
“If the compiler accepts my concurrency code, the ordering is correct.”
No. Swift can enforce many isolation and sendability rules, but it does not know whether your feature intends latest-wins, first-wins or ordered merging.
“Moving the state into an actor fixes every race.”
No. It can fix unsafe access to that state. Valid actor operations can still interleave or arrive in an unwanted logical order.
“Cancelling the old task guarantees it cannot update the UI.”
No. Cancellation is cooperative. Validate relevance before committing state when stale updates must be impossible.
“The last request to start will finish last.”
No. Network conditions, caching, server work and scheduling can all change completion order.
“Completion order is always wrong.”
No. Completion order is useful when the feature wants the first available result or progressive rendering. It becomes a bug only when the required semantics are different.
13 · TUTORIAL
What to Remember
• A logical race is an ordering bug between otherwise valid operations.
• Logical-race code can be fully actor-isolated and free of data races.
• Actors serialize state access; they do not choose product semantics.
• Define the policy first: latest wins, first wins, merge all or preserve order.
• Use operation identity or generations to reject stale results.
• Cancellation saves obsolete work but is not, by itself, a commit guarantee.
• Do not mistake task-group completion order for input order.
• Test adverse orderings with controlled completion, not timing guesses.
14 · TUTORIAL
Frequently Asked Questions
What is a logical race in Swift Concurrency?
It is a correctness bug where valid concurrent operations produce an unwanted result because their start, completion or commit order differs from the feature's intended order.
Is a logical race the same as a data race?
No. A data race concerns unsafe overlapping memory access. A logical race can occur while every memory access is safely actor-isolated.
Do actors prevent logical races?
Not automatically. Actors serialize isolated state access, but application code must still decide which operation may update that state.
Does Task cancellation prevent stale results?
Not by itself. Cancellation is cooperative. Combine it with a relevance check when an obsolete result must never commit.
How do I implement latest request wins?
Assign each request an identity or generation, then confirm that identity is still current after every suspension and before changing visible state. Cancel older work as an additional optimization.
Can logical races happen on MainActor?
Yes. Main-actor state access is serialized, but async main-actor operations can suspend and later commit results in an unwanted order.
15 · TUTORIAL
References
16 · TUTORIAL
Continue Learning
We now understand that isolation and application ordering are separate concerns. The next article, What Does nonisolated Mean in Swift?, explains which actor members can be used without entering the actor, which state they cannot access and why nonisolated says nothing about background execution.
17 · TUTORIAL
Download the Xcode Playgrounds
Use Understanding Logical Races.playground to reproduce stale launch results with controlled completion, then open Logical Race Challenges.playground to practise latest-wins, ordered aggregation and first-result policies.
