Two callers. One refresh. No lost edits.
A loading flag can make a screen look protected while its API still gives callers the wrong promise:
func refresh() async throws {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
items = try await itemsAPI.downloadItems()
}The second caller returns immediately even though the refresh it requested has not finished. If it continues by reading the results, it may read yesterday’s data. Disabling one button does not fix this for another screen, a lifecycle callback or another Feature.
Coordination belongs where callers meet
In CFA, each Feature defines how its operations handle overlapping calls. A ViewModel can coordinate its screen, but only the Feature sees all the entry points into its capability.
First decide whether the calls ask for the same work. Two refreshes for the same account and filter may be equivalent. Saving one value and then a different value is not. Neither is refreshing another account. “There is already a task” is not enough information to make that decision.
Equivalent refreshes share completion
This Feature has one fixed provider configuration and one unparameterised refresh operation. Equivalent callers join the retained task. The task publishes the downloaded items before completing, so successful waiters observe the refreshed state.
import Observation
@MainActor
@Observable
final class ItemsFeature {
private let itemsAPI: any ItemsDownloading
private var refreshTask: Task<Void, Error>?
private(set) var items: [Item] = []
init(itemsAPI: any ItemsDownloading) {
self.itemsAPI = itemsAPI
}
func refresh() async throws {
try Task.checkCancellation()
if let refreshTask {
try await refreshTask.value
try Task.checkCancellation()
return
}
let task = Task { [self, itemsAPI] in
defer { refreshTask = nil }
let downloadedItems = try await itemsAPI.downloadItems()
items = downloadedItems
}
refreshTask = task
try await task.value
try Task.checkCancellation()
}
}The retained task owns the complete shared operation, including publication and cleanup. Failures propagate to callers instead of becoming an empty list. Existing items remain visible until a successful download replaces them. Cleanup runs after either outcome, allowing a later retry.
The cancellation policy is explicit: cancelling one waiter does not cancel the shared download. That waiter checks its own cancellation before admission and after a successful wait. It may therefore wait for the operation to finish before receiving cancellation. If the download fails first, it receives that failure instead. Prompt individual waiter cancellation would require a different implementation.
The task strongly retains this Feature until it finishes. That is intentional for shared work which can outlive a screen, and the handle is cleared on completion. A provider that can hang indefinitely needs a bounded request policy; storing a task is not a timeout.
Distinct edits must not be mistaken for duplicates
Imagine an inline quantity changing from 12 to 13 to 14 while persistence is slow. Returning the task that saves 12 for all three requests would discard the later values.
If every command matters, queue whole operations: admit one, finish its asynchronous persistence and state publication, then start the next. Each caller must receive its own outcome. A failed command must not strand the queue, and admitted work must not call back into the same queue and await itself.
For an autosaved draft, deliberately replacing pending intermediate values with the latest value can sometimes be appropriate. That is a different product contract from recording every event. Do not silently switch between them during a tidy-up.
An actor is not a whole-operation queue
Actor isolation protects isolated state from concurrent access, but an asynchronous actor operation can suspend and allow another operation to run. A multi-step workflow is not made atomic simply by placing it in an actor. Swift describes this as actor reentrancy.
Keep admission, operation execution and cleanup explicit. Preserve concurrency between unrelated work: the fact that one record needs ordered writes does not mean every Feature should wait behind it.
Test both promises
For shared refreshes, hold the provider suspended, call the Feature twice and verify one request began. Neither successful caller should finish before publication. Release it, then repeat with failure and retry. Also cancel one waiter and confirm the other still receives the download.
For a queue, submit distinct commands while the first is suspended. Check the persisted order and each caller’s result. Fail the first command and verify the next still runs. These are different tests because sharing and queueing make different promises.
Documented CFA rationale: overlap policy belongs at the shared capability boundary, and each caller receives the outcome of its requested work. Editorial trade-off: sharing requires a precise definition of equivalence; queues increase waiting time. Neither is the right answer to every operation.
Choose the promise, then implement it
A clearer Feature API says more than “I am busy”. It says whether your work is shared, queued, replaced or intentionally independent. Use the CFA AI Toolkit for iOS to make that ownership visible:
Using CFA for iOS, tidy up overlapping requests in this code.
Scope: Swift 6, explicit MainActor isolation and Observation, for iOS 17+. ItemsDownloading is a lower-level provider substitution boundary; it is not a FeatureAPI protocol. The shown implementation handles one fixed refresh scope, not keyed account/filter requests or an edit queue. Written CFA rationale and editorial trade-offs are distinguished explicitly.