The user signed out. What is still alive in get_it?
What closing a get_it scope does to retained references, asynchronous initialization and disposal failures. Four code examples examine where session cleanup ends and application responsibility begins.
- Published
- September 18, 2026
- Verified
- September 14, 2026
The user signed out. What is still alive in get_it?
A sign-out button rarely looks like the complicated part of an application. Remove the user's data, close connections and return to the sign-in screen. If the user's dependencies live in a separate scope, much of the cleanup can come down to one call: await getIt.popScope().
That is a convenient arrangement. Particularly when every object has finished being created and nothing fails during disposal.
Now let the user sign out a little earlier, while a service is still preparing to run. The scope closes, but the service's creation continues. Eventually, the ready object reaches the code that requested it. By then, the user has already left.
Who should close that object? Why did it arrive after its scope was removed?
A few small examples with get_it 9.2.1 make the problem easier to follow. There is no need to start with a lifecycle framework. First, a guest object becomes a user object. Then cleanup takes time. Finally, creation and disposal fail to fit the expected sequence.
One dependency, two objects
Suppose an application needs a data service. Before sign-in, it works as a guest; afterwards, it works for the signed-in user. Both objects have the same type, but belong to different situations.
The examples use a small Resource instead of a real connection. Its name distinguishes the guest from the user, while closed records whether cleanup ran. There are no omitted methods behind this class:
import 'dart:async';
import 'package:get_it/get_it.dart';
class Resource {
Resource(this.name);
final String name;
bool closed = false;
}
Each following example uses this class inside a separate async function. The first starts by registering the guest resource:
final services = GetIt.asNewInstance();
services.registerSingleton<Resource>(Resource('guest'));
print(services<Resource>().name); // guest
services.pushNewScope(scopeName: 'session');
services.registerSingleton<Resource>(
Resource('user'),
dispose: (resource) { resource.closed = true; },
);
final retained = services<Resource>();
print(retained.name); // user
await services.popScope();
print(services<Resource>().name); // guest
print(retained.name); // user
print(retained.closed); // true
registerSingleton stores one object for requests made by its type, Resource. pushNewScope opens a new group of registrations above the existing ones. A matching registration in this upper scope takes precedence during lookup. The guest object remains underneath; it is neither replaced nor destroyed.
Closing the scope invokes the supplied dispose callback and removes the user registration. The next lookup returns guest again. Dependency selection has done what was needed.
The variable retained, however, still refers to user. A container cannot walk through the application and replace every reference it previously returned. If a screen received the service before sign-out, closing the scope does not take that reference away.
This alone does not establish a memory leak. A caller might need to inspect the outcome of work already started. But using a closed object as though its lifetime had not ended requires a rule of its own. A real service must determine which calls remain valid after closure and which should be rejected. The flag in this example merely records what happened; it does not enforce anything.
Cleanup does not always finish immediately
Setting closed = true takes little work. A real service might first need to finish a write or stop an activity it owns. That is why popScope() returns a Future: a disposal callback may itself be asynchronous.
No slow connection is needed to check this. The callback can pause at a known point and resume only when allowed. Dart's Completer provides a future to wait on and a complete() method to finish that wait.
Here, disposalStarted tells the calling code that cleanup has begun. allowDisposal keeps it paused:
final services = GetIt.asNewInstance();
final disposalStarted = Completer<void>();
final allowDisposal = Completer<void>();
services.registerSingleton<Resource>(Resource('guest'));
services.pushNewScope(scopeName: 'session');
services.registerSingleton<Resource>(
Resource('user'),
dispose: (resource) async {
disposalStarted.complete();
await allowDisposal.future;
resource.closed = true;
},
);
var logoutCompleted = false;
final logout = services.popScope().then((_) {
logoutCompleted = true;
});
await disposalStarted.future;
print(logoutCompleted); // false
print(services<Resource>().name); // user
allowDisposal.complete();
await logout;
print(services<Resource>().name); // guest
While disposal is waiting, the scope has not finished closing. Lookup still returns the user resource. Once the callback completes, the container removes the scope and the guest object becomes visible again.
Leaving out await is therefore more than a stylistic issue. The application proceeds without waiting for the operation it requested. It could begin preparing the next user's environment while the previous one is still being released.
The presence of await does not establish the opposite extreme either: that every activity associated with the session has ended. It waits for a particular operation. The next example shows work that outlives it.
The service is ready. The session is gone
Some services cannot be created immediately. They first need configuration or a prepared connection. registerSingletonAsync accepts an asynchronous creation function—a factory—instead of an already available object.
Ordinary synchronous lookup is premature while that creation is still pending. getAsync waits for the object, and allReady waits for the relevant registrations to become ready. Neither method decides whether the current user still needs the result.
In the next example, the factory starts but cannot return until explicitly released. Its scope closes in the meantime. There is no guest registration in this example, so isRegistered can show that the dependency has disappeared entirely:
final services = GetIt.asNewInstance();
final creationStarted = Completer<void>();
final allowCreation = Completer<void>();
var disposalCalls = 0;
services.pushNewScope(scopeName: 'session');
services.registerSingletonAsync<Resource>(
() async {
creationStarted.complete();
await allowCreation.future;
return Resource('late-user');
},
dispose: (resource) {
disposalCalls++;
resource.closed = true;
},
);
final pendingResource = services.getAsync<Resource>();
final pendingReadiness = services.allReady();
await creationStarted.future;
await services.popScope();
print(services.isRegistered<Resource>()); // false
allowCreation.complete();
final lateResource = await pendingResource;
await pendingReadiness;
print(lateResource.name); // late-user
print(lateResource.closed); // false
print(disposalCalls); // 0
The order is deliberate: request the object, close the scope, then receive the result. No network timing or carefully chosen delay is hiding behind the output.
popScope() completes and the registration is gone. Yet the factory that was already running continues and returns an object. Its disposal callback has not run. The allReady future obtained before closure also finishes after the factory returns; it has not become evidence that the session is still active.
The distinction is simple, though its consequences may not be: removing a registration and cancelling creation are different operations. There was no ready object when the scope closed. The object arrived later, after the scope itself had disappeared.
The application needs someone to receive that late result and decide its fate. It might wait for creation before disposal, if that wait is acceptable. It might cancel preparation, if the operation supports cancellation. Or it might accept the result, check whether it still belongs to the active session and close an object that is no longer needed.
These are alternatives to investigate, not fixes established by this experiment. A quick configuration read and a long connection attempt need not follow the same policy. What the example establishes is where the decision belongs: with the code that starts preparation and receives its result, rather than with an assumption that deleting a registration stops all related execution.
Disposal failed. Try again?
Another reasonable expectation is that a failed cleanup can be retried. If the second call completes without an error, surely cleanup succeeded this time.
Two objects are enough to test that expectation. No asynchronous factory is needed. One disposer behaves normally; the other throws:
final services = GetIt.asNewInstance();
var firstDisposed = false;
services.registerSingleton<Resource>(Resource('guest'));
services.pushNewScope(scopeName: 'session');
services.registerSingleton<Resource>(
Resource('first'),
instanceName: 'first',
dispose: (_) { firstDisposed = true; },
);
services.registerSingleton<Resource>(
Resource('user'),
dispose: (_) { throw StateError('Disposal failed'); },
);
try {
await services.popScope();
} on StateError {
print('First attempt failed');
}
print(firstDisposed); // false
print(services.hasScope('session')); // true
await services.popScope(); // Completes without an error.
print(services.hasScope('session')); // true
print(services<Resource>().name); // user
instanceName allows another object of the same type to be registered separately. Its purpose here is to make it visible whether cleanup reaches the other callback.
Version 9.2.1 disposes objects in reverse registration order. The user callback runs first and throws. The first callback is never reached, and the scope remains. The first call reports the failure clearly enough.
The next call is the surprising one. It completes without error, but the scope still exists and lookup returns user. Retrying did not resume the interrupted cleanup.
The implementation marks a scope as being popped before starting cleanup. A later call returns immediately if that flag is already set. Removing the scope comes after object disposal. The exception interrupted the first call between those steps; the second saw the flag and did nothing.
In this case, “the retry completed” does not mean “the scope closed.” An application that reports successful sign-out solely because this await completed could report an event that never happened.
That is behavior of the specified version under the shown failure. It does not justify abandoning get_it, nor does it make resetting the entire container a safe answer to every disposal error. It does justify checking the failure path of sign-out—especially if the next step allows another user to sign in.
What should “signed out” actually mean?
For a simple flow, closing the scope and returning to guest dependencies may be sufficient. Trouble begins when that one operation is silently expected to stop new work, cancel existing work, dispose late results and recover from cleanup failures as well.
A useful review follows the same path as the examples. Find where user-specific services begin preparation. Identify the code holding their references. Check whether preparation can finish after sign-out and who will receive that result. Then deliberately fail one disposer and make sure the interface does not announce completion merely because the next call threw nothing.
This does not require a large session-management subsystem in advance. It requires answers to specific questions. If a separate object needs to coordinate session completion, its responsibility will emerge from those answers, not from choosing a class name.
The ArkTelos Lab experiment supports further investigation: change the call order, add an application-specific failure or repeat the checks against another version. The code needed to understand the central cases is already above; reading the test harness is optional. The same practice is useful when adopting ArkTelos: establish what a tool actually does at a scenario boundary before deciding what the application needs around it.
About the checks
The examples are adapted from the get_it 9.2.1 experiment, with the shortened versions checked separately. The full harness has eight cases and saved results from four processes on Dart 3.12.2, macOS arm64: two with assertions enabled and two without. Additional checks cover object identity, disposal order and asynchronous creation failure.
There is no real authentication, network connection or memory measurement here. closed is a fixture flag, not proof of a closed connection. After observing the late result, the harness closes it manually; after the deliberate disposal failure, it clears the container without invoking the callbacks again. That is test cleanup, not a proposed fix. All possible event orders and an asynchronously failing disposer were not separately tested.
ArkTelos Lab | ArkTelos website
Channels: Lab EN | Lab RU | ArkTelos EN | ArkTelos RU.
Result boundaries
- get_it 9.2.1, Dart 3.12.2, macOS arm64. Eight cases across four fresh processes, two with assertions and two without. Synthetic resources; no networking, Flutter, real authentication or memory measurement. Not all event orderings or asynchronous disposal failures were tested. Harness cleanup is not a fix.
CODE / DATA / AGENTS
Related experiments
get-it-lifecycle-trialInspect retained references, awaited cleanup, late factories and retries after disposal failure.
get_it 9.2.1, Dart 3.12.2, macOS arm64. Eight cases across four fresh processes, two with assertions and two without. Synthetic resources; no networking, Flutter, real authentication or memory measurement. Not all event orderings or asynchronous disposal failures were tested. Harness cleanup is not a fix.
Open the experiment record →Telegram edition