Reproducing race conditions with deterministic Swift tests
How to turn intermittent concurrency bugs into repeatable tests using controlled tasks, barriers, timeouts, and explicit scheduling points.
On this page
Race conditions are expensive because they rarely fail when the right person is watching.
A user taps twice, a refresh arrives during a save, a task is cancelled while another task still owns the state, and the app ends up with a duplicate record or a spinner that never stops. The team tries to reproduce it, adds a few prints, runs the flow twenty times, and gets nothing except a growing suspicion that the bug has developed legal representation.
That is the wrong loop.
A useful race-condition test does not hope the scheduler gets unlucky. It makes the interesting interleaving explicit. The test should control when work starts, where it pauses, when competing work enters, and what state must be true after both paths finish.
1. Write the race as a timeline
Before writing test code, write the failing interleaving in plain text.
For example:
Given cached profile state is stale
Task A starts refreshProfile()
Task A reads the old cache value
Task B starts refreshProfile()
Task B writes the new value
Task A resumes and writes its older result last
Then the cache contains stale dataThat timeline is already more useful than "refresh is flaky." It names:
- the shared state
- the two competing operations
- the pause point
- the bad final write
- the invariant the product needs
Do this before adding sleeps, retries, or logging. A race that cannot be described as a timeline is usually not understood well enough to test. It might be a lifecycle bug, cancellation bug, persistence bug, or plain old state modeling bug wearing concurrency's jacket.
The timeline also tells you what kind of test you need. If the bug depends on two domain operations interleaving, a unit or integration test may catch it. If it depends on app lifecycle, backgrounding, networking, or SwiftUI task cancellation, keep the deterministic core small and put the full system behavior in a slower scenario test.
2. Shrink the boundary until scheduling is possible
Deterministic race tests need a controllable boundary.
This is hard to test:
final class ProfileViewModel {
func refresh() {
Task {
let profile = try await URLSession.shared.profile()
self.profile = profile
}
}
}The work creates its own task, uses global networking, mutates state directly, and exposes no useful synchronization point. A test can tap the button and hope. Hope is not a concurrency primitive.
A better shape moves the race-prone operation behind a testable async boundary:
@MainActor
final class ProfileModel {
private let client: ProfileClient
private var refreshGeneration = 0
private(set) var profile: Profile?
init(client: ProfileClient) {
self.client = client
}
func refresh() async throws {
refreshGeneration += 1
let generation = refreshGeneration
let loaded = try await client.loadProfile()
guard generation == refreshGeneration else { return }
profile = loaded
}
}Now the test can control ProfileClient. It can start one refresh, pause it, start another refresh, complete them in the wrong order, and prove that stale work does not win.
That is the target: make the unit small enough that the test can force the dangerous order instead of begging the runtime to accidentally discover it.
3. Add explicit suspension points to test doubles
Most deterministic race tests need a double that can pause at the exact point where production code would await I/O, persistence, notification delivery, or another actor.
A simple gate is often enough:
actor AsyncGate {
private var isOpen = false
private var continuations: [CheckedContinuation<Void, Never>] = []
func wait() async {
if isOpen { return }
await withCheckedContinuation { continuation in
continuations.append(continuation)
}
}
func open() {
guard !isOpen else { return }
isOpen = true
continuations.forEach { $0.resume() }
continuations.removeAll()
}
}Then use it inside a fake dependency:
actor ControlledProfileClient: ProfileClient {
let firstRequestStarted = AsyncGate()
let allowFirstRequestToFinish = AsyncGate()
private var requestCount = 0
func loadProfile() async throws -> Profile {
requestCount += 1
if requestCount == 1 {
await firstRequestStarted.open()
await allowFirstRequestToFinish.wait()
return Profile(name: "Old")
}
return Profile(name: "New")
}
}The production code still sees an async client. The test gets a handle on the important ordering.
Be careful with gates. They are sharp tools:
- open them exactly once unless the type explicitly supports multiple waiters
- make their names describe the scheduling point
- keep them inside test doubles, not production logic
- add timeouts around waits so a broken test fails instead of embalming CI
If a test hangs forever, it is not deterministic. It is just flaky with better stationery.
4. Wait for evidence, not time
The weakest race-condition test is a sleep followed by an assertion.
try await Task.sleep(for: .milliseconds(300))
#expect(model.profile?.name == "New")That test is both slow and unreliable. On a fast run, it wastes time. On a slow run, it can fail for reasons unrelated to the behavior being tested. It also hides the scheduling contract: why 300 milliseconds, and what event was supposed to happen during that window?
Prefer named evidence:
@Test
func staleRefreshCannotOverwriteNewerRefresh() async throws {
let client = ControlledProfileClient()
let model = await ProfileModel(client: client)
async let first: Void = model.refresh()
await client.firstRequestStarted.wait()
try await model.refresh()
await client.allowFirstRequestToFinish.open()
try await first
let profile = await model.profile
#expect(profile?.name == "New")
}The important point is not this exact helper implementation. It is the shape:
- start work A
- wait until A reaches the dangerous point
- run work B
- release A
- assert the product invariant
That is a test someone can read six months later. It explains the race without requiring them to inspect a failed CI video like a crime scene analyst.
5. Control task creation from the test
Detached task creation inside production code makes races harder to test.
This pattern is common:
func saveTapped() {
Task {
try await save()
}
}It is acceptable at the UI adapter edge, but it should not be the core behavior. The testable operation should still be async:
func saveTapped() {
saveTask = Task {
await runSaveFlow()
}
}
func runSaveFlow() async {
// test this directly
}That split gives you two kinds of tests:
- a small test for
runSaveFlow()and its interleavings - a UI or adapter test that proves tapping starts and cancels the task correctly
If every meaningful operation hides inside an unstructured Task, tests lose control over lifetime, cancellation, and ordering. The app also loses control. Conveniently, the bill arrives in production.
When the product needs at-most-one behavior, model that explicitly:
@MainActor
final class ImportModel {
private var importTask: Task<Void, Never>?
func startImport() {
guard importTask == nil else { return }
importTask = Task { [weak self] in
await self?.runImport()
await MainActor.run { self?.importTask = nil }
}
}
}Then test the invariant: two starts should not create two imports, cancellation should release the slot, and completion should clear ownership.
Do not make the test infer that from random side effects. Count calls, expose state snapshots, or return operation results.
6. Use barriers for shared mutable state
Some races are not about ordering network responses. They are about two operations touching the same state at once.
A classic example is duplicate creation:
func createDraftIfNeeded(id: UUID) async throws -> Draft {
if let existing = drafts[id] {
return existing
}
let draft = Draft(id: id)
drafts[id] = draft
return draft
}If this code is isolated to one actor and contains no suspension between the read and write, it may be safe. If the real version awaits validation, persistence, or remote data between those steps, the race comes back:
func createDraftIfNeeded(id: UUID) async throws -> Draft {
if let existing = drafts[id] {
return existing
}
try await validator.validate(id)
let draft = Draft(id: id)
drafts[id] = draft
return draft
}The test should force both callers past the initial check before either writes. A reusable barrier can help:
actor AsyncBarrier {
private let targetCount: Int
private var arrived = 0
private var isReleased = false
private var continuations: [CheckedContinuation<Void, Never>] = []
init(count: Int) {
self.targetCount = count
}
func wait() async {
if isReleased { return }
arrived += 1
if arrived == targetCount {
isReleased = true
continuations.forEach { $0.resume() }
continuations.removeAll()
return
}
await withCheckedContinuation { continuation in
continuations.append(continuation)
}
}
}The fake validator waits at the barrier. Once both operations arrive, both continue. The test can then prove whether the domain operation still preserves uniqueness.
The production fix might be:
- keep the check-and-write inside one actor turn with no suspension
- insert a pending placeholder before awaiting
- use an idempotent database constraint
- route creation through a single operation queue
- return the same in-flight task for duplicate requests
The test should not care which fix you choose. It should care that one logical draft exists at the end.
7. Test cancellation as part of the race
Many Swift concurrency bugs are cancellation bugs with better marketing.
The user leaves the screen. A .task(id:) restarts. A search query changes. A sync operation is replaced by a newer one. The old task keeps running and writes state after it no longer owns the result.
Test that directly:
@Test
func cancelledSearchDoesNotPublishResults() async throws {
let search = ControlledSearchService()
let model = await SearchModel(service: search)
let oldTask = Task {
await model.search("old")
}
await search.firstRequestStarted.wait()
oldTask.cancel()
await model.search("new")
await search.finishFirstRequest(with: [.oldResult])
let results = await model.results
#expect(results == [.newResult])
}The exact API will vary, but the invariant should be stable: cancelled or superseded work must not publish stale state.
Good production code usually checks ownership after every meaningful suspension:
let response = try await service.search(query)
try Task.checkCancellation()
guard query == currentQuery else { return }
results = response.itemsThat guard is not paranoia. It is the price of allowing multiple timelines to touch one screen.
8. Keep actor reentrancy in the test vocabulary
Actors protect isolated state from simultaneous access. They do not make a multi-step async operation atomic across suspension points.
Inside an actor, this can still be vulnerable:
actor SessionStore {
private var token: Token?
func refreshToken() async throws -> Token {
if let token, token.isValid {
return token
}
let refreshed = try await authService.refresh()
token = refreshed
return refreshed
}
}While authService.refresh() is suspended, another call can enter the actor and make its own decision from the current state. That is actor reentrancy. It is not a compiler bug. It is the actor doing exactly what Swift actors do.
A deterministic test should start two refreshes, pause the first service call, let the second proceed, then release the first. The assertion should prove the store does not publish an older token or perform duplicate refresh work if the product requires coalescing.
A common fix is to store the in-flight task:
actor SessionStore {
private var token: Token?
private var refreshTask: Task<Token, Error>?
func refreshToken() async throws -> Token {
if let token, token.isValid {
return token
}
if let refreshTask {
return try await refreshTask.value
}
let service = authService
let task = Task { try await service.refresh() }
refreshTask = task
do {
let refreshed = try await task.value
token = refreshed
refreshTask = nil
return refreshed
} catch {
refreshTask = nil
throw error
}
}
}Then test both callers receive the same result and the service was called once. That is the kind of assertion that turns "actors are safe, right?" into an actual guarantee.
9. Put timeouts around every controlled wait
A deterministic test can still deadlock when the code changes.
That failure should be fast and diagnostic. Swift Testing supports time limits at the test level, and your helpers can add operation-level timeouts where needed.
Use timeouts to explain which event did not happen:
try await waitUntil(
"first refresh reached cache read",
timeout: .seconds(1)
) {
await client.firstRefreshReachedRead
}A useful timeout message says what scheduling point was expected. A useless timeout says "operation timed out" and leaves the next engineer to conduct archaeology with a plastic spoon.
Keep timeouts short for unit tests. If a deterministic unit test needs ten seconds, something is wrong with the boundary. Save longer budgets for integration tests that involve real persistence, networking, simulator lifecycle, or system services.
10. Separate deterministic tests from stress tests
Stress tests still have a place.
Running an operation 1,000 times with randomized scheduling can reveal races the team did not model yet. That is useful for discovery. It is not a replacement for a deterministic regression test.
Use both layers deliberately:
Deterministic tests should:
- encode a known bad interleaving
- run quickly
- fail with a precise explanation
- guard a production invariant
- be part of the normal pull request suite
Stress tests should:
- run many randomized iterations
- collect enough diagnostics to explain failure
- live in a slower suite when necessary
- produce a deterministic regression test after they find a bug
If a stress test fails once and nobody can reproduce it, the job is not done. The failure gave you a lead, not a fix.
The team should turn that lead into a named timeline, a controlled test double, and one boring assertion that stays green for the next hundred releases.
11. The production baseline
For Swift apps with real concurrency, I want this baseline:
- race reports written as timelines, not vague flake descriptions
- async operations exposed separately from UI task creation
- test doubles with explicit gates and barriers
- assertions based on named evidence, not arbitrary sleeps
- ownership checks after meaningful suspension points
- cancellation and superseded-work behavior tested directly
- actor reentrancy treated as normal, not surprising
- timeouts around every controlled wait
- idempotency or uniqueness enforced at the domain or persistence boundary
- stress tests used for discovery, deterministic tests used for regression
The goal is not to make Swift's scheduler predictable. The goal is to stop depending on it for proof.
Once the dangerous interleaving is under test control, the bug stops being folklore. It becomes a small sequence of events with a final invariant. Much less dramatic. Considerably more useful.