The screen closed, but the operation did not: ownership of asynchronous work in Flutter
A route can disappear while its operation continues. A deterministic Flutter experiment separates UI delivery, source cancellation, operation ownership, and the handling of late values and errors.
- Published
- September 9, 2026
- Verified
- September 3, 2026
The screen closed, but the operation did not: ownership of asynchronous work in Flutter
A user edits a profile and taps Save. The button becomes disabled and a request starts. Before the response arrives, the user navigates back.
One second later, the server confirms the change. The screen no longer exists, but the data has been modified. Replace profile saving with search suggestions, and the late result may be entirely useless. Replace it with a payment, and treating route disposal as proof of cancellation becomes dangerous.
At the Flutter boundary, these scenarios initially look the same. A
BuildContext becomes unmounted between an await and the next UI call. The
usual correction is necessary:
final result = await repository.saveProfile(profile);
if (!context.mounted) {
return;
}
Navigator.of(context).pop(result);
This check prevents access to an invalid UI context. It does not decide what happens to the operation.
The exact boundary of mounted
Flutter documents BuildContext.mounted in strict terms: properties and
methods are valid only while the context is mounted, and a context that has
become unmounted will never be mounted again. State.dispose is likewise a
terminal lifecycle point for that State object. Subclasses should release
retained resources and subscriptions there, and calling setState afterward
is an error.
Two consequences are direct:
- code must not update a disposed UI after an asynchronous gap;
- a screen must detach from resources and subscriptions it owns.
A third consequence is often added without being stated: disposing the screen is assumed to cancel every operation it started. Flutter makes no such guarantee.
dispose describes the lifetime of a presentation object. An HTTP request,
database write, isolate computation, or backend command can have a different
lifetime. Flutter also warns that dispose is not guaranteed during abrupt
process termination. An operation whose correctness depends entirely on that
callback already has a lifecycle problem outside the widget tree.
The screen is gone. The user's intent may not be.
A Future reports completion; it does not own the work
Future<T> represents the eventual result of an asynchronous computation. It
can complete with a value or an error, and it can remain incomplete. Its
general API has no cancel() method.
timeout is sometimes treated as cancellation:
final result = await request.timeout(const Duration(seconds: 10));
The contract is narrower. This branch stops waiting for the original Future after the limit. It does not prove that a socket was closed, a computation was stopped, or a remote system will not apply the request later.
StreamSubscription.cancel() has a different guarantee. The subscription no
longer receives events, and the returned Future completes after stream cleanup.
The documentation says the stream may need to shut down its event source.
That distinction matters: stopping delivery to one subscriber and stopping the
underlying source are not universally the same operation.
The architecture needs separate names for separate events.
Operation, source, delivery, and owner
An operation is one attempt to carry out an intent: save a profile, upload a file, or load suggestions. Its source is the mechanism doing the work, such as an HTTP client, database, stream, isolate, or remote service. Delivery is the transfer of progress, a value, or an error to a particular consumer.
The operation owner is the component responsible for:
- the identity of this execution;
- the lifetime of the intent;
- its terminal outcome;
- cancellation and cleanup policy;
- result delivery;
- late completion after a consumer disappears.
The class name is secondary. A small screen can own a local lookup. A feature object can outlive one route. An application service can own synchronization, while a backend owns a long-running server operation.
Responsibility comes before the architectural label.
Four questions instead of one cancel()
A project can usually expose the missing boundary by answering four questions.
Is the delivery target still alive?
For Flutter UI, mounted and the subscription lifecycle answer this question.
If the target is gone, the application must stop delivering to that UI. Nothing
has been decided about the source yet.
Is the result still useful?
Suggestions for a closed search field are usually disposable. A confirmed upload, profile save, or synchronization job may remain useful after the route has gone. A payment needs an even stronger rule: closing the screen proves neither cancellation nor success.
Can the source actually be stopped?
Cancellation belongs to the concrete API. One client can abort a request; another merely stops waiting for the response. A write may already have reached the server. Once a side effect is committed, cancellation can turn into a separate compensation operation.
Who observes late completion?
A late value can be discarded, persisted, recorded in history, or exposed to a new consumer. A late error still needs an observer. Removing the UI listener does not make an unhandled asynchronous failure safe.
One isDisposed flag cannot answer all four questions.
Three policies tested against the same route lifecycle
The accompanying Flutter experiment uses a controlled source rather than a
real network request. The source completes only when instructed by the test.
This makes it possible to close a real MaterialPageRoute first and deliver a
value or error afterward without depending on timers or machine speed.
Three ownership policies were compared.
The operation belongs to the screen
Under the UI-scoped policy, the result is meaningful only to the current screen. Disposal detaches delivery and requests cooperative cancellation:
void detach() {
_deliver = null;
if (policy == OwnershipPolicy.uiScoped) {
source.requestCancellation();
}
}
The controlled source supports that signal. The test observes the cancellation
request, and the operation reaches the terminal outcome cancelled. No value
is stored.
This policy fits work whose purpose exists only inside the current presentation context: a disposable preview, a local hint, or a query that can safely be started again.
The operation belongs to the application
Under the application-scoped policy, the screen is only a temporary observer. Closing the route detaches UI delivery, but the source continues. The operation owner retains the late value:
final value = await source.execute();
if (policy == OwnershipPolicy.applicationScoped) {
storedResult = value;
}
_deliver?.call('completed:$value');
After route disposal, the controlled source completed with profile-saved.
There was no UI callback and no Flutter exception. The owner stored the result.
mounted remains necessary at the screen boundary. The operation itself does
not need to know about it. Presentation subscribes and unsubscribes; the
application owner continues according to its own lifecycle.
Only delivery is canceled
The third source cannot be stopped. Route disposal removes UI delivery, but an owner still observes the Future. The source then completes with a synthetic error.
The test confirms both sides of the boundary: the disposed UI is not called,
and the late error is not lost. The operation reaches failed, and the owner
retains the error for the next policy decision.
Delivery-only cancellation is not merely a fallback. It is the honest contract when the source cannot be stopped or when an external side effect has already crossed its cancellation point. The application stops promising impossible control while continuing to observe the outcome.
The same user action, three different outcomes
All three tests follow the same sequence:
- open a route;
- start an operation;
- close the route before completion;
- complete or cancel the controlled source;
- inspect source cancellation, delivery, and terminal outcome separately.
Only the ownership policy changes:
| Policy | Source cancellation requested | Late UI delivery | Terminal outcome | Value retained |
|---|---|---|---|---|
| UI-scoped | yes | no | cancelled |
no |
| Application-scoped | no | no | completed |
yes |
| Delivery-only | no | no | failed in the error case |
no; error observed |
context.mounted has the same value in every row. It cannot choose the row.
What a shared execution mechanism can own
As an application grows, it usually needs a component that tracks individual executions. The minimum responsibility follows from the scenario:
- assign an identity to each operation;
- distinguish processing from a terminal outcome;
- expose cooperative cancellation;
- reject further result emissions after terminal completion;
- retain bounded diagnostic history;
- observe a handler error that arrives after cancellation.
This mechanism can be application-specific or provided by a package. One
implementation from the ArkTelos ecosystem is
UseCase Forge. Its
usecase_forge 1.0.0 package
represents the typed request as a Command submitted to a UseCase, the component
handling that product operation. Each run becomes an Execution, which publishes
Snapshots and records terminal Entries in bounded history. The source examined
here is
pinned in GitLab at commit 16f8347.
The relevant part of its contract is cancellation. context.cancel()
immediately finalizes the Execution as cancelled and releases its processing
slot. The package explicitly does not claim to abort an arbitrary Dart Future.
The handler must observe isCancellationRequested or
whenCancellationRequested and stop at a cooperative boundary.
In the experiment, a handler waited for an external Completer. After
context.cancel(), the Execution was already present in history as
cancelled, while the external Future remained incomplete. The Future later
received a value. The handler observed cancellation and did not publish the
late result; the terminal history remained unchanged.
Finalizing an Execution and stopping its source were again two distinct acts.
Where usecase_forge stops
The package can formalize a product operation lifecycle. It cannot select the product policy on the application's behalf.
It does not decide:
- whether leaving a route cancels the user's intent;
- whether an HTTP client can abort its request;
- whether a late result should be retained;
- whether the UI should show a dialog or navigate;
- how an operation survives process termination;
- how to compensate for an already committed side effect.
The presentation layer owns its UI subscription. An infrastructure adapter translates a cooperative signal into real I/O cancellation when the specific API supports it. A repository or another storage boundary owns persistence. The UseCase connects command, execution, and terminal result without silently claiming the neighboring lifetimes.
ArkTelos enters the analysis only here: as one bounded implementation of an already established responsibility, not as a package-shaped answer to every lifecycle question.
Failure paths that matter more than the happy path
Late success is the easiest scenario. The harder production cases follow.
An error arrives after UI delivery is detached
The absence of a UI listener does not remove error-handling responsibility. If
the operation continues, someone must still observe its Future. The error may
become diagnostics, a terminal result, or a recovery decision, but it must not
disappear between dispose and completion.
Cancellation is acknowledged after the side effect
A cancellation acknowledgement does not prove that an external system changed nothing. A write operation needs an operation identifier and a way to discover its actual outcome. Blind retry can create a second effect instead of repairing the first one.
The process dies without dispose
Flutter explicitly warns that lifecycle callbacks are not guaranteed during abrupt application termination. A significant operation needs durable state in a component that outlives the process: a backend, database, platform service, or another persistence boundary. An in-memory owner cannot provide that.
A new screen subscribes later
An application-scoped result needs a replay policy. Should a new screen read current state, consume one terminal result, or inspect history? This is the neighboring state/result/effect delivery problem. A broadcast Stream does not select the correct semantics automatically.
A practical review checklist
The audit should start where UI code launches work, not by counting every
mounted check:
- What did the user ask the application to do?
- Does that intent end when the route closes?
- Who retains the execution identity and terminal outcome?
- What does the concrete source API actually cancel?
- Who observes a late value and a late error?
- Could a side effect already be committed when cancellation is requested?
- What will a new screen or restored process observe?
If State.dispose is the answer to every question, the boundaries have
probably not been designed yet.
Evidence boundary
All four fixture tests—three route-lifecycle policies and one usecase_forge
cancellation test—passed on Flutter 3.47.2, Dart 3.13.2, and
usecase_forge 1.0.0 on macOS arm64. The run used a separate temporary SDK:
the owner's working Flutter installation was not updated, and the isolated SDK
and its caches were removed after verification.
The controlled source does not prove the cancellation semantics of every HTTP
client. An in-memory storedResult is not persistence. Widget disposal is not
process death. The experiment establishes a narrower mechanism: route disposal,
delivery cancellation, and operation completion remain independent events
until the architecture connects them with an explicit policy.
The resulting boundary is concrete. mounted protects Flutter API access. The
operation owner protects the meaning of the user's intent.
Sources and reproduction
- Flutter
BuildContext.mounted - Flutter
State.dispose - Flutter SDK archive
- Dart
Future<T> - Dart
StreamSubscription.cancel - UseCase Forge
usecase_forge1.0.0 on pub.devusecase_forge1.0.0 source in GitLab- source of the
async-operation-after-route-closeexperiment and its verified revision9f52fc8
ArkTelos Lab: website | Telegram EN | Telegram RU
ArkTelos: website | Telegram EN | Telegram RU
Result boundaries
- The experiment does not establish one universally correct ownership policy; the choice depends on the lifetime of the product intent and the concrete source API.
- The tested UseCase Forge boundary provides cooperative cancellation and a terminal result but cannot forcibly stop an arbitrary Future or own Flutter navigation and UI effects.
CODE / DATA / AGENTS
Related experiments
async-operation-after-route-closeCompare three explicit ownership policies when a real Flutter route closes before a controlled asynchronous source finishes.
The controlled source proves application sequencing but not the cancellation semantics of any HTTP, database, isolate, or platform API. Widget disposal is not process death, and the in-memory stored result is not durable persistence.
Open the experiment record →Telegram edition