Your Swift code compiles. Strict concurrency is happy. Your actors isolate their state. Your tasks respond to cancellation.

And your architecture can still be wrong.

The hardest concurrency question is often not where does this task execute? It is:

Who owns the work — and when does that work stop mattering?

Modern Swift gives us structured concurrency, Task, actors, Sendable, AsyncSequence, task groups, and cooperative cancellation. None of those mechanisms, however, can decide what an operation means to the product.

A view disappearing is a UI event. A task being cancelled is a concurrency event. A business operation becoming irrelevant is a domain event.

Sometimes those events should happen together. Sometimes they absolutely should not.

The architecture is the decision that connects them.

Correct Swift Can Still Encode the Wrong Architecture

Consider a live-session feature:

The code is perfectly reasonable. The ownership decision hidden inside it may not be. Attaching observeSession() to .task implicitly ties asynchronous work to the view’s lifecycle.
The code is perfectly reasonable. The ownership decision hidden inside it may not be. Attaching observeSession() to .task implicitly ties asynchronous work to the view’s lifecycle.

This is idiomatic SwiftUI. But there is an architectural statement hidden inside it:

Two lifetimes that happen to start together do not necessarily belong together. The view may disappear while the product operation it initiated still matters.
Two lifetimes that happen to start together do not necessarily belong together. The view may disappear while the product operation it initiated still matters.

By placing the operation inside .task, we have associated its lifetime with this presentation.

That might be correct. But what if observeSession() maintains a live connection, persists events, tracks an activity already started by the user, synchronizes a shared resource, completes an upload, or observes a transaction that must reach a terminal state?

Why should the lifetime of that domain operation be determined by whether one SwiftUI view is visible?

A Task is a unit of execution. It is not necessarily a unit of product work.

The Lifetimes We Keep Collapsing

A screen, a task, a feature, a resource, and a domain operation may begin together—but they do not necessarily end together. Good concurrency architecture makes those lifetimes explicit instead of letting the UI lifecycle decide them accidentally.
A screen, a task, a feature, a resource, and a domain operation may begin together—but they do not necessarily end together. Good concurrency architecture makes those lifetimes explicit instead of letting the UI lifecycle decide them accidentally.

A view is presentation. A task is execution. A feature owns user-facing state and behavior. A resource might be a stream, sensor, file handle, subscription, or connection. A domain operation is the thing the user actually asked the product to do.

Suppose a user starts uploading a large video and switches tabs. The presentation changed. The product intent probably did not.

If our architecture equates those lifetimes, the UI accidentally becomes the owner of the business operation.

Start With Intent, Not With Task

Before creating a task, model the operation:

Architecture starts before the Task. Define the intent, operation, owner, resources, and result—then let Swift express the lifetime.
Architecture starts before the Task. Define the intent, operation, owner, resources, and result—then let Swift express the lifetime.

For a live session:

A live session is not a single task—it is a coordinated runtime. The user’s intent activates a session that owns multiple resources and data flows—snapshot, stream, persistence, and analysis—which converge into a consistent session state that can out
A live session is not a single task—it is a coordinated runtime. The user’s intent activates a session that owns multiple resources and data flows—snapshot, stream, persistence, and analysis—which converge into a consistent session state that can out

Instead of asking "Where should I put my Task?", ask:

I know what owns this operation. How should Swift express that lifetime?

The code becomes a consequence of the architecture.

Cancellation Is Product Semantics

Four operations can all begin with Task { ... } and still have completely different cancellation semantics.

Search — superseded intent

Cancellation can represent superseded intent, not failure. Each new search owns the latest request, cancels obsolete work, and prevents stale results from reaching UI state—keeping concurrency aligned with what the user currently wants.
Cancellation can represent superseded intent, not failure. Each new search owns the latest request, cancels obsolete work, and prevents stale results from reaching UI state—keeping concurrency aligned with what the user currently wants.

Here cancellation means: this intent is obsolete.

Preview rendering — no remaining consumer

If an editor closes and nobody needs an expensive preview, cancellation can mean: the result no longer has a consumer.

The same rule applies frame by frame in a Core ML or Vision pipeline. If the frame an inference was analysing has already been replaced, the result has no consumer. Dropping or cancelling that work is pure gain.

Upload — navigation is not cancellation

Long-lived work needs an explicit owner. The UploadManager owns each upload beyond the screen that started it, tracks its lifecycle, and makes cancellation a deliberate domain action—not an accidental consequence of navigation.
Long-lived work needs an explicit owner. The UploadManager owns each upload beyond the screen that started it, tracks its lifecycle, and makes cancellation a deliberate domain action—not an accidental consequence of navigation.

The important change is not the actor. It is ownership.

There is one more owner above UploadManager: the operating system. An in-process Task survives navigation, but it does not survive suspension or termination. For a large video, the transport belongs to a background URLSession (URLSessionConfiguration.background(withIdentifier:), uploading from a file), which keeps transferring while the app is suspended and relaunches it to deliver completion events.

UploadManager then owns the product view of the upload — identity, progress, retries, user cancellation — while the system owns the transport. Two owners, two lifetimes, both explicit. (If the user force-quits the app, the system cancels those transfers. That is a product decision too, just not ours.)

Purchase — presentation is not transaction authority

When a server-side billing system is involved, the authority moves again: the client reports, the backend decides. In neither case does a view decide.

Same primitive. Different product semantics..

.task Is Not Wrong

.task is excellent when work genuinely belongs to presentation lifetime.

Sometimes the view really should own the work. When recommendations exist only to populate the current presentation, SwiftUI’s .task naturally aligns execution with the view lifecycle: appear, fetch, update, disappear—and cancel when the result is no
Sometimes the view really should own the work. When recommendations exist only to populate the current presentation, SwiftUI’s .task naturally aligns execution with the view lifecycle: appear, fetch, update, disappear—and cancel when the result is no

The mistake is allowing .task to decide ownership for us.

Framework convenience should express an architectural decision, not silently make one.

Separate Presentation From Runtime

Suppose a live session must remain active while the user navigates through related screens.

A longer-lived runtime can own the resource:

The runtime owns the continuity of the session, not the screen. SessionRuntime consumes the event stream, evolves and persists state, serves consistent snapshots, and treats cancellation as an intentional lifecycle decision—keeping resource ownership
The runtime owns the continuity of the session, not the screen. SessionRuntime consumes the event stream, evolves and persists state, serves consistent snapshots, and treats cancellation as an intentional lifecycle decision—keeping resource ownership

Notice what the actor did not do for us. It serializes access, but every await is a suspension point where other work can run. A naive snapshot() that awaited the network and then assigned the result would silently overwrite events the stream applied in the meantime, and two concurrent callers would trigger two fetches. The in-flightsnapshotTask and the event replay exist because of reentrancy, not isolation.

Actors serialize access. They do not serialize intent.

Better — but not finished.

Actors Solve Isolation, Not Ownership

An actor answers an isolation question. It does not automatically answer a lifecycle question.

Who owns SessionRuntime?

If SessionFeature creates and destroys it, we may have moved the original bug down one layer. If the application owns every runtime forever, we have the opposite problem.

If two features request the same session, should we open two streams? Who owns reconnection? What terminates on sign-out?

No actor keyword answers those questions.

Shared Resources Change the Problem

Two presentations may need one underlying resourc

The resource might conceptually behave like:

A shared resource should live as long as it has consumers—not as long as any single view. The first consumer opens it, additional consumers reuse it, and only the last departure closes it. Ownership makes that lifecycle explicit and predictable.
A shared resource should live as long as it has consumers—not as long as any single view. The first consumer opens it, additional consumers reuse it, and only the last departure closes it. Ownership makes that lifecycle explicit and predictable.

This is not a prescription to reference-count everything. It demonstrates a principle:

Resource lifetime should be derived from resource semantics.

Structured Concurrency Is Still the Default

When child work really belongs to a parent operation, structure is ideal:

Structured concurrency works best when the lifetime of the child work matches the lifetime of the parent operation. profile, activity, and recommendations can run concurrently because they all belong to one goal: producing a complete Dashboard. Here,
Structured concurrency works best when the lifetime of the child work matches the lifetime of the parent operation. profile, activity, and recommendations can run concurrently because they all belong to one goal: producing a complete Dashboard. Here,
One parent operation, three concurrent tasks, one consistent result.
One parent operation, three concurrent tasks, one consistent result.

If an upload should survive a view, however, this is the wrong tree:

The problem is not structured concurrency. The problem is structuring work according to implementation convenience instead of product semantics.

Stored Tasks Are an Architectural Signal

Whenever you write:

private var task: Task<Void, Never>?

you should be able to answer:

  1. Who owns this task?
  2. What starts it?
  3. What ends it?
  4. What does cancellation mean?
  5. Who observes its result?
  6. What happens if the owner disappears?
  7. Can multiple callers start equivalent work?
  8. What happens on sign-out, backgrounding, or process termination?

If those answers are vague, the task may represent a responsibility that has not been modeled yet.

Since Swift 6.2 you can even write the answer to the first question into the code: Task(name: "Upload \(id)") { ... }. The name shows up when you debug and profile, so the task explains who it belongs to.

Task.detached Should Trigger a Stronger Question

Task.detached has legitimate uses, but the review question should be:

Why must this work be detached from the current task hierarchy?

"Because I don't want cancellation" is not a lifecycle model.

"Because it needs to run in the background" conflates concurrency, isolation, scheduling, application background execution, persistence, and domain lifetime.

If an operation must survive a screen, model an owner whose lifetime survives the screen.

Sendable Is an Architectural Boundary Signal

Sendable makes boundaries explicit. Pass immutable state across concurrency domains—not the mutable machinery that produced it.
Sendable makes boundaries explicit. Pass immutable state across concurrency domains—not the mutable machinery that produced it.

Compare a value like that with a mutable controller holding a connection, cache, and state.

Perhaps the thing that should cross an isolation boundary is not the controller. Perhaps it is a snapshot, event, command, or identifier.

That makes Sendable more than a compiler hurdle. It can force us to ask what our boundaries actually carry.

@unchecked Sendable may occasionally be justified, but it transfers the proof obligation from the compiler to us.

Did we prove the type is safe to transfer, or did we merely make the diagnostic disappear?

Let Lifetimes Follow Semantics

Separate lifetimes by responsibility. SwiftUI owns presentation, the feature model owns UI state, and the runtime owns domain resources and services.
Separate lifetimes by responsibility. SwiftUI owns presentation, the feature model owns UI state, and the runtime owns domain resources and services.

Navigation may destroy presentation without destroying the runtime. In another feature, such as search suggestions, presentation and operation lifetime may intentionally be almost identical.

The same holds across technology boundaries. In a Flutter app with native modules, the platform channel is an ownership boundary: a Dart widget being disposed should not, by itself, decide whether a native purchase, upload or capture session ends.

There is no universally correct Task lifetime. There is a lifetime that correctly represents the product operation.

@MainActor Is a Boundary, Not a Default Architecture

Keep UI state on the MainActor. Immutable snapshots flow in, observable state changes, and SwiftUI reacts.
Keep UI state on the MainActor. Immutable snapshots flow in, observable state changes, and SwiftUI reacts.

UI state is often a natural main-actor boundary. But marking an entire dependency graph @MainActor merely to silence diagnostics can turn a useful boundary into accidental architecture.

Ask whether the state is presentation state, whether a resource actually requires main-actor isolation, whether CPU-heavy analysis belongs there, and whether values — not mutable controllers — should cross the boundary.

This matters more since Swift 6.2. New Xcode 26 projects can make MainActor the default isolation for unannotated code (SE-0466), and with Approachable Concurrency, nonisolated async functions run on the caller's actor unless they are marked @concurrent (SE-0461). Those defaults remove a lot of noise. They also make it easy to never decide where the boundary is.

Use @concurrent deliberately for CPU-heavy analysis. Keep the runtime off the main actor because you chose to, not because a diagnostic pushed you there.

Isolation should describe the system. It should not merely appease the compiler.

Failure and Cancellation Are Different States

Cancellation is control flow, not failure. Handle it explicitly so real errors remain meaningful.
Cancellation is control flow, not failure. Handle it explicitly so real errors remain meaningful.

Even this depends on semantics.

A domain operation explicitly cancelled by the user may need a visible .cancelled state. A superseded search usually should not display "Search cancelled." A resource disappearing unexpectedly may be a failure rather than cancellation.

Architecture should preserve those meanings instead of reducing everything to an Error.

Test the Contract, Not the Task

Test product semantics, not task mechanics. Verify what survives, what stops, and who owns each lifetime.
Test product semantics, not task mechanics. Verify what survives, what stops, and who owns each lifetime.

These tests do not care whether production code stores a particular Task. They test product contracts, leaving the implementation room to evolve.

A Master-Level Code Review Checklist

When reviewing concurrent Swift, do not stop at "Is this thread-safe?"

Ask:

  1. What product operation does this asynchronous work represent?
  2. Who owns that operation?
  3. Does the owner's lifetime match the operation's lifetime?
  4. What makes the result obsolete?
  5. What does cancellation mean in this domain?
  6. Should navigation affect the operation at all?
  7. Is this resource shared?
  8. Who closes or evicts it?
  9. Are actor boundaries modeling isolation or hiding unclear ownership?
  10. Are Sendable values crossing boundaries, or are mutable responsibilities leaking across them?
  11. Are we testing task mechanics or product semantics?
  12. What happens during sign-out, suspension, restoration, and failure?

Those questions expose bugs that perfectly valid Swift syntax cannot.

The Broader Lesson

Swift's concurrency system makes many unsafe programs harder to write.

That is a major improvement.

But compiler-enforced safety cannot choose product semantics for us.

The compiler can help determine whether data may cross an isolation boundary. It cannot decide whether leaving a screen should terminate an upload.

An actor can protect mutable state. It cannot decide who should own the actor.

Structured concurrency can propagate cancellation through a task tree. It cannot decide whether that tree represents the correct product hierarchy.

SwiftUI can tie asynchronous work to presentation lifetime. It cannot decide whether the business operation belongs to that presentation.

Those decisions remain architecture.

And architecture starts by asking what the work means.

Before You Write the Next Task

Draw this first:

Design the lifetime before the task. Start with intent, define ownership and resources, then let concurrency implement the operation.
Design the lifetime before the task. Start with intent, define ownership and resources, then let concurrency implement the operation.

Then ask:

Only then choose the Swift mechanism.

Sometimes the answer will be .task.

Sometimes it will be structured child tasks.

Sometimes a feature model should own the task.

Sometimes the operation belongs to an actor-backed runtime or application service.

The goal is not to avoid Task.

The goal is to stop letting Task define architecture accidentally.

Final Thought

We often talk about Swift Concurrency as a problem of execution:

Where does this code run?

Modern Swift increasingly helps us answer that safely.

The harder production question is about ownership:

Who is responsible for this work while it matters?

And beyond ownership lies the most important question:

When does it stop mattering?

That answer rarely comes from the framework.

It comes from the product.

A Task is a unit of execution. It is not necessarily a unit of product work.

Design the lifetime first.

Then write the task.

Swift in Practice — engineering Swift beyond syntax.

Originally published as a Swift in Practice edition on LinkedIn. Comments and discussion happen there.