Tasking is a small Swift package for making unstructured task ownership
explicit at UI and application boundaries.
It provides three deliberately separate types across two products:
ViewTaskStore: owns task handles started from synchronous UI callbacks such asButtonactions, and makes lifetime plus duplicate policy visible.ActionRunner: runs work in an existing async context, tracks action state, and returns a typed outcome without creating a task.TaskSlot(TaskingCore): owns one replaceable task outside UI isolation and can wait for cancelled or superseded work to actually terminate.
The package is intentionally not a replacement for structured concurrency. Use
async let, task groups, and SwiftUI .task whenever their scope matches the
work. Use ViewTaskStore only when work must outlive a synchronous callback and
therefore needs explicit ownership.
Apple's Swift concurrency guidance draws a strong line between structured tasks and unstructured tasks:
- Structured tasks (
async letand task groups) live inside a scope and get automatic cancellation/error bookkeeping from the task tree. Task {}andTask.detached {}are unstructured. They are useful at non-async boundaries, but cancellation, errors, results, and lifetime must be managed explicitly.- Cancellation is cooperative. Calling
cancel()requests cancellation; the running operation must check cancellation or call APIs that react to it. - SwiftUI
.taskis already view-lifetime-bound and automatically cancelled when the view disappears, so it should remain the preferred tool for lifecycle loading.
Primary references:
- WWDC21: Explore structured concurrency in Swift
- WWDC23: Beyond the basics of structured concurrency
- WWDC21: What's new in SwiftUI
- Task.cancel() documentation
- SwiftUI View.task documentation
Design rationale and vocabulary live in docs/:
- Glossary — domain vocabulary and a quick "which tool when" table
- Positioning — problem statement, design principles, comparison
with alternatives (SwiftUI
.task(id:), VergeGroup TaskManager, TCA, async-task), and an honest strengths/weaknesses assessment - Adoption Guide — large-app adoption policy, ActionID governance, Swift 5 language-mode caveat, and 0.2 roadmap
- Architecture Decision Records — why the API is shaped the way it is
// Add to Package.swift
.package(url: "https://github.com/9uiLe/swift-tasking.git", from: "0.2.0").product(name: "Tasking", package: "swift-tasking")
// Add this only to non-UI targets that need explicit unstructured-task ownership.
.product(name: "TaskingCore", package: "swift-tasking")import Tasking
// Or, from a non-UI target:
import TaskingCoreTasking requires the Swift 6 toolchain and compiles in Swift 6 language mode. The package manifest uses Swift tools version 6.0 so clients do not need a newer SwiftPM just to load the package.
Supported Apple deployment targets:
- iOS 13+
- macOS 10.15+
- tvOS 13+
- watchOS 6+
- visionOS 1+
Swift 5 language mode is intentionally not supported. This package is about making Swift Concurrency task ownership explicit, so strict data-race checking is part of the public quality bar.
Swift 5 language-mode targets may be able to import the package, but their closure captures are checked under the consuming target's language mode. In practice, non-Sendable captures that Swift 6 would reject can compile silently from Swift 5 targets. Treat Swift 6 language mode in consuming feature modules as part of the support boundary.
Use TaskSlot from the TaskingCore product when a non-UI owner must start an
unstructured task and replace it with newer work. It is an actor and has no
MainActor, SwiftUI, lifetime-label, or business-error policy.
import TaskingCore
actor SearchRefreshCoordinator {
private let taskSlot = TaskSlot()
func scheduleRefresh() async {
await taskSlot.replace { [weak self] cancellation in
do {
try await Task.sleep(for: .milliseconds(250))
try cancellation.check()
await self?.refreshLatestQuery()
} catch is CancellationError {
return
} catch {
await self?.recordRefreshFailure(error)
}
}
}
func cancelAndSettle() async {
await taskSlot.cancel()
await taskSlot.waitForIdle()
}
}replace requests cancellation of the previous operation but does not assume it
has stopped. Superseded operations remain owned until they finish, and
waitForIdle() waits for all of them. Operations must cooperate through the
provided CancellationContext. The optional priority is forwarded to Swift's
Task initializer; leave it nil to inherit the caller's priority.
Do not call waitForIdle() from an operation owned by the same slot; that would
wait for the current operation to finish from inside itself. Keep debounce timing,
domain state, retries, persistence, and error handling in the consuming feature.
Use ViewTaskStore at synchronous UI boundaries where await is not available.
import SwiftUI
import Tasking
private enum SettingsActions {
static let save: ActionID = "settings.save"
}
struct SettingsScreen: View {
@State private var viewTaskStore = ViewTaskStore()
@State private var viewModel = SettingsViewModel()
var body: some View {
Button("Save") {
viewTaskStore.start(
id: SettingsActions.save,
lifetime: .screenBound,
policy: .ignoreNew
) { cancellation in
try await viewModel.save(cancellation: cancellation)
}
}
.onDisappear {
viewTaskStore.cancel(lifetime: .screenBound)
}
}
}TaskStartPolicy makes duplicate behavior local and reviewable:
.ignoreNew: keep the current run and skip the new request..cancelExisting: request cancellation for current runs, then start a new run. The cancelled operation may continue until it cooperates with cancellation..allowConcurrent: track multiple overlapping runs for the same action ID.
ViewTaskStore.start passes a CancellationContext into the operation. Pass it
to the ViewModel method and call try cancellation.check() in long-running work
so cancellation requests are handled deliberately.
@MainActor
final class SettingsViewModel {
func save(cancellation: CancellationContext) async throws {
do {
try cancellation.check()
try await settingsUseCase.save()
try cancellation.check()
state = .saved
} catch let error as CancellationError {
throw error
} catch {
state = .failed(error)
}
}
}Business errors should be converted to ViewModel state before they leave the
operation. ViewTaskStore treats CancellationError as a normal cancellation;
other uncaught errors are considered programming mistakes and trigger a debug
assertion.
ActionLifetime has built-in .screenBound, .sceneBound, and .appBound
values, and it can be extended with string literals:
let lifetime: ActionLifetime = "accountSettings"
viewTaskStore.cancel(lifetime: lifetime)- Running queries are tracking queries.
isRunningandrunningCountreport what Tasking is currently tracking; they do not prove that no cancelled work is still executing.cancel(id:)andcancel(lifetime:)request cancellation and remove runs from tracking immediately, so.ignoreNewdoes not guard against old work that ignores cancellation after a manual cancel. - UI state belongs to the ViewModel. If a ViewModel sets loading state before
work begins, reset that state before rethrowing
CancellationError. - With
.cancelExisting, cleanup from the old run can arrive after the new run has started. Guard cleanup with a generation token when both runs mutate the same ViewModel state. - Do not let an operation strongly capture its
ViewTaskStoreor an object that owns that store. The cyclestore -> task -> operation -> storeprevents the store's deinit cancellation safety net from running until the operation finishes. Use explicitcancel(...)and weak captures when the operation needs to call back into an owner. - Do not create nested unstructured tasks inside an operation.
CancellationContextreflects the current task; escaping work intoTask {}creates a new ownership and cancellation boundary. Preferasync letor task groups for inner concurrency.
See Recipes for the concrete patterns validated by the prototype tests.
Use ActionRunner when you are already in an async context and want duplicate
handling plus a typed terminal result.
let runner = ActionRunner()
let outcome = await runner.run(ActionDescriptor(id: "billing.refresh")) { cancellation in
try cancellation.check()
try await billingUseCase.refresh()
try cancellation.check()
}
switch outcome {
case .succeeded:
break
case .cancelled:
break
case .skipped(.alreadyRunning):
break
case let .failed(error):
logger.error("\(error.typeName): \(error.message)")
}ActionRunner also passes CancellationContext to the operation, but it does
not own or cancel a task. It only lets the current task's cancellation state be
checked explicitly while it manages duplicate policy and outcome mapping. Keep
cancellation ownership in ViewTaskStore, SwiftUI .task, task groups, or the
caller's existing structured task.
- Prefer structured concurrency first.
- Use SwiftUI
.taskfor view lifecycle work. - Use
ViewTaskStore.start(...)for synchronous user-action callbacks. - Use
TaskSlotonly when a non-UI owner must create and replace unstructured work. - Keep
Task.detachedout of feature code unless the work intentionally should not inherit actor, priority, task-local values, or cancellation context. - Keep nested
Task {}out of Tasking operations; use structured child work. - Make action IDs constants, not scattered string literals.
- Treat cancellation as a request. Long-running ViewModel methods should accept
CancellationContextand calltry cancellation.check()before expensive work and after important suspension points.