NSProgress on macOS: Composable Progress, Cancellation, and Task Ownership
Report multi-stage macOS work with weighted Progress trees, honest indeterminate states, cooperative cancellation, and UI that reflects task ownership.
Progress (historically NSProgress in Objective-C-facing documentation) communicates how much of an operation is complete. It can aggregate child operations into a larger task, expose localized status, and communicate whether pausing or cancellation is supported. It does not perform the work, schedule it, or guarantee that a child operation will stop. The component doing the work must update counts and implement cancellation cooperatively.
That separation is important for product behavior. A progress bar that reaches 100 percent while a file is still being committed is misleading; a cancel button that only sets a Boolean while a network request continues indefinitely is not real cancellation. Define the work boundary and the point at which the result becomes durable before choosing progress units.
Choose units that match observable work
totalUnitCount and completedUnitCount are an accounting model, not a measure of elapsed time. Choose units with a stable relationship to work the user can understand: files processed, records migrated, bytes transferred, or pages rendered. Do not report one unit per microscopic loop iteration if updating progress costs more than the work. Apple notes that progress properties are observable and updates have a nonzero cost; report in useful batches.
If total work is unknown, expose an indeterminate state instead of inventing a denominator that repeatedly changes. When the total becomes known, switch to a determinate progress model only if the transition will make sense to the user. Keep localizedDescription and localizedAdditionalDescription task-oriented: “Downloading 3 of 8 files” is more useful than a bare percentage for long-running work.
Build a weighted progress tree
A parent progress represents a composite operation. Child progress objects each consume a declared number of pending parent units. The weights need not equal the child’s own totalUnitCount; the parent weight describes how much of the overall task that phase represents. For example, download may consume 70 parent units and verification plus installation the remaining 30. If stages vary wildly in duration, choose weights from measured product behavior and do not present them as precise time estimates.
import Foundation
func makeImportProgress() -> (overall: Progress, download: Progress, parse: Progress) {
let overall = Progress(totalUnitCount: 100)
let download = Progress(totalUnitCount: 1_000)
let parse = Progress(totalUnitCount: 100)
overall.addChild(download, withPendingUnitCount: 75)
overall.addChild(parse, withPendingUnitCount: 25)
return (overall, download, parse)
}
Update each child only from the component that owns its work. If the transfer layer reports bytes, map completed bytes to the child total with overflow-safe arithmetic and account for unknown content length. If the parser reports records, increment after a record is successfully processed, not merely when it is dequeued. Parent progress aggregates the tree; do not also increment its completed units for the same child work or it will double-count.
If a phase can be skipped, define how its assigned units are completed or reallocate the parent’s remaining work explicitly before it begins. Otherwise the parent can remain below 100 percent forever even though the user-visible operation succeeded. Similarly, if a child fails permanently, finish the overall operation with an error state rather than forcing its unit count to the maximum to make the bar look complete.
Cancellation is cooperative and owned by the work
Calling cancel() marks progress as cancelled and invokes its cancellation handler; child progress is cancelled as well. That notification must reach the actual work owner. A parser can check cancellation at safe checkpoints, a URL session task can be cancelled, and a file operation can stop between atomic chunks. A section already committed to durable storage may need cleanup or compensation instead of interruption halfway through.
Set isCancellable according to whether the operation can honor the user’s request. If cancellation can only be accepted before a commit boundary, disable the control after that boundary and explain the transition. The property can change during the lifetime of the operation. Do not expose a cancellation action that the implementation silently ignores.
struct Record {
let identifier: Int
}
func processRecords(
_ records: [Record],
progress: Progress,
save: (Record) throws -> Void
) throws {
progress.totalUnitCount = Int64(records.count)
for (index, record) in records.enumerated() {
if progress.isCancelled { return }
try save(record)
progress.completedUnitCount = Int64(index + 1)
}
}
The sample returns on cancellation between records. If save has non-interruptible work, cancellation will be observed only after that call completes. Do not mark the overall operation successful merely because the helper returned; callers should inspect cancellation and define cleanup. For long-running loops, use bounded checkpoints so a cancel request has a predictable response time.
Cancellation and task cancellation in Swift concurrency are related ideas but are not automatically interchangeable. If an async task is cancelled, propagate that state into the operation’s Progress if the UI observes it; if a user cancels the progress, cancel the owned task or transfer. Avoid creating two independent cancellation flags that can disagree. Choose one coordinator as the source of truth and make every adapter idempotent.
Observe from the UI without coupling it to the worker
An NSProgressIndicator presents progress; it does not own the business operation. Bind or observe fractionCompleted, isIndeterminate, and the localized descriptions from a controller or view model. Ensure updates arrive on the UI’s required actor before changing AppKit controls. If work continues after a window closes, the progress UI may disappear while the operation owner remains alive; if the window owns the task, closing it should cancel according to a stated policy.
Avoid creating a new Progress on every view refresh. The model object should live as long as the operation, and observers should be attached and removed with the UI lifecycle. Progress objects support observation, but high-frequency updates can produce unnecessary UI work. Throttle display refreshes for very fast operations while preserving useful progress on slower ones.
Parent-child accounting and implicit current progress
Foundation supports explicit child relationships and a current-progress mechanism that can associate nested operations with a parent on the current thread. Explicit addChild(_:withPendingUnitCount:) is easier to audit when stages are asynchronous or owned by separate components. The implicit mechanism can be convenient for nested synchronous APIs, but its current-progress scope is thread-oriented; do not assume a thread-local association automatically follows an async task across executors.
When integrating a framework that already reports progress, attach its progress object to your operation if the API supports it. Do not wrap it with a guessed timer. The parent tree should reflect actual child work, while the application handles user-facing phase transitions and final result state.
Failure, pause, and retry semantics
Progress can expose pause and resume, but the underlying operation must implement those actions. A transfer may support pause/resume with a resume token under the API’s documented constraints; a database migration may only support cancellation between transactions. Set the capabilities honestly and define what happens when a pause cannot be resumed after relaunch.
Retry is not additional completed work unless the product defines it that way. If an upload retries three times, counting every transmitted byte as forward progress can make the indicator move backward or reach 100 too early. Track separate telemetry for attempts and user-visible completion. For retryable stages, either measure unique logical work or show an indeterminate state while the final total is unknown.
Acceptance checks
Test zero total units, one-step work, a failed child, a skipped phase, cancellation before and during a child, pause when supported, relaunch with a persisted operation, and a child that completes after the parent view disappears. Assert that the parent never exceeds its intended range, that canceled work does not publish success, and that all UI observation is removed when the presenter is deallocated.
Log operation ID, phase, total and completed units, cancellation request time, last checkpoint, and terminal outcome. Do not log private file names or content when the task processes user data. Measure time from cancel request to a safe stop; a useful cancel control has a bounded response behavior that is tested, not merely a Boolean property.
Use progress as a contract among the worker, coordinator, and UI: the worker reports honest completion, the coordinator owns cancellation and aggregation, and the UI explains the result. The framework supplies the accounting structure, but only application code can ensure that the displayed state matches durable reality.
Related:
- macOS Background Maintenance with NSBackgroundActivityScheduler
- Grand Central Dispatch on macOS: Queues, QoS, Barriers, and Deadlocks
Sources: