Test iOS optimistic updates and stale responses on a cloud Mac
A favorite button changes appearance as soon as you tap it, then rolls back to the wrong state after a network failure. Or you sign out and back in, only for an old request to overwrite the new user’s interface. You do not need to wait for a slow connection to reproduce these bugs. In a build job on a cloud Mac, isolate the optimistic update logic in a state machine, then feed it success, failure, and session-change events in a controlled order.
Define a single in-flight write contract first
This article uses a straightforward policy that is easy to verify: allow at most one in-flight write per record. Tapping again while a request is pending does not send another request. The interface keeps the current provisional value and shows that saving is in progress. This gives up rapid toggling in exchange for an unambiguous rollback target.
Keep three pieces of state: the confirmed value, the displayed value, and the in-flight request ticket. Starting an operation changes only the displayed value. Success updates both values with the server’s response; an explicit rejection restores the confirmed value. Each record in a list owns its own state rather than sharing a global Boolean flag.
An optimistic update displays intent early; it does not prove that a save succeeded. A client-side ticket also cannot replace server-side control over write ordering.
Define the boundary for unknown outcomes
A timeout or dropped connection does not mean the server rejected the write. You can withdraw the provisional feedback, but the record should enter a state that requires synchronization. Read its value from the server before allowing further operations. The failure branch below represents only a business-rule rejection known not to have committed; it is not a general handler for all network errors.
Implement a UI-independent state machine
Save the following as optimistic-check.swift. Each ticket has a distinct identifier that is not reused across session changes. The model neither starts network requests nor depends on a UI framework, so it can run directly on a node with the command-line tools installed.
import Foundation
struct FavoriteState {
private(set) var confirmed: Bool
private(set) var visible: Bool
private(set) var pending: UUID?
init(_ value: Bool) {
confirmed = value
visible = value
}
mutating func begin(_ desired: Bool) -> UUID? {
guard pending == nil else { return nil }
let ticket = UUID()
pending = ticket
visible = desired
return ticket
}
mutating func finish(_ ticket: UUID, saved: Bool?) {
guard pending == ticket else { return }
if let saved {
confirmed = saved
}
visible = confirmed
pending = nil
}
mutating func replaceSession(with value: Bool) {
pending = nil
confirmed = value
visible = value
}
}
The optional value in finish has a specific contract: a value represents the final state confirmed by the server, while nil means the write is known not to have committed. Business rules may cause the server to return a value different from the one requested, so the success path must not simply copy the user’s intended value.
In production, isolate the state machine to the main actor and return to that actor before modifying it when a network operation completes. Do not call these methods concurrently from two callback queues; using a value type does not automatically eliminate data races.
Test event ordering instead of waiting for the network
Append the following assertions to the same file. They require neither sleeps nor actual requests. Although the ticket identifiers are random, the checks depend only on equality, so the results remain deterministic.
var state = FavoriteState(false)
let first = state.begin(true)!
precondition(state.visible && !state.confirmed)
precondition(state.begin(false) == nil)
state.finish(first, saved: nil)
precondition(!state.visible && state.pending == nil)
let second = state.begin(true)!
state.finish(second, saved: true)
precondition(state.visible && state.confirmed)
state.finish(second, saved: false)
precondition(state.visible)
let oldSession = state.begin(false)!
state.replaceSession(with: false)
let current = state.begin(true)!
state.finish(oldSession, saved: false)
precondition(state.pending == current && state.visible)
state.finish(current, saved: false)
precondition(!state.visible && !state.confirmed)
Run it as follows. Do not add optimization options that disable precondition checks for this compilation.
set -eu
xcrun swiftc optimistic-check.swift -o optimistic-check
./optimistic-check
A nonzero exit status must fail the job. The first group checks rollback after failure and repeated taps. The second checks duplicate callbacks. The third checks responses from an old session and a server-confirmed value that differs from the user’s intent. There is no need to print account identifiers or full response bodies.
Preserve record identity when connecting the network layer
A real request should capture the record identifier, the current session identity, and the ticket. When the response arrives, first verify that it still belongs to the same record, then pass the result to that record’s state machine. When list cells are reused, do not locate the target by row number alone: sorting and filtering both change row numbers.
When switching accounts, replace the state of every record and reload authoritative data. The example’s replaceSession method demonstrates the reset performed after obtaining the initial value for the new session. It does not permit continued use of an old value after sign-out.
Canceling a request only reduces unnecessary work; it does not replace ticket validation. By the time cancellation occurs, the server may already have committed the write, and the callback may already be queued. If multiple devices can modify the same record, the server must also provide version preconditions or conflict results. A local single-in-flight policy cannot handle cross-device contention.
Make state checks a release gate
Before merging the code, cover three cases: the button becomes usable again after a business-rule rejection; an unknown outcome blocks further writes and triggers synchronization; and callbacks from an old account do not change the new account’s state. Alongside state-machine checks, use UI tests to verify that the saving indicator, accessibility state, and tap blocking are actually bound to pending.
When you find an incorrect rollback, first preserve the event sequence, a de-identified record identifier, and the outcome category. Then check for stale row numbers or models carried across sessions. Do not hide the problem by extending wait times. The final acceptance criterion is that every state change is justified by an event—not that the button happens to look correct at the end.
Frequently asked questions
Does a timeout mean the server did not save the change?
No. The outcome is unknown. You may revert the provisional display, but fetch authoritative server state before allowing another write.
Can a local request ticket prevent server-side write races?
No. It only prevents stale callbacks from updating the current interface. Server writes still need serialization or version-based conditions.
Add a Physical Mac to Your Build Pipeline
Choose Apple Silicon M4 or M4 Pro. Each machine is dedicated to one customer, never shared as a virtual machine. Pick the hardware for your build workload, then choose a billing term that works for your team.
Rent a Physical Node