The Problem
Imagine you need to sync a local database with a remote server. The sequence looks roughly like this: open a SQLite connection, fetch an auth token over HTTP, open a WebSocket, begin a DB transaction, subscribe to a WebSocket stream, receive CSV data, batch-upsert rows, and finally tear everything down in reverse order.
Any step can fail. The user can cancel at any moment β mid-handshake, mid-stream, whenever. If step 5 throws, you need to clean up resources from steps 1β4, in reverse order, and none of those cleanups should prevent the others from running even if they throw too.
The naive approach looks like this:
Future<void> sync() async {
Database? db;
HttpClient? http;
WebSocket? ws;
try {
db = await openDatabase();
try {
http = HttpClient();
final token = await http.getToken();
try {
ws = await WebSocket.connect(url, token);
try {
await db.transaction((txn) async {
// subscribe, stream, upsert...
});
} finally {
ws.close();
}
} finally {
http.close();
}
} catch (_) {
// What if we need fallback behavior here?
rethrow;
}
} finally {
db?.close();
}
}
This is already painful at 4 levels. With 8 steps and conditional branches it becomes unmaintainable. Each new resource adds another nesting level, late or nullable variables leak into the outer scope, and if any finally block throws β the original error is silently swallowed.
Prior Art: Other Languages Solved This
This problem is well-known. Different languages address it differently:
- Go has
deferβ each call pushes a cleanup onto a LIFO stack that unwinds when the function exits. - C++ has RAII β destructors run automatically in reverse order of construction when the scope ends.
- C# has
using/IDisposableβ scoped resource management with deterministic cleanup. - D has
scope(exit)β explicit scope guards that execute on scope exit regardless of how it exits.
Dart has none of these. But we can build the equivalent in about 10 lines.
The Pattern
void Function() dispose = () { dispose = () {}; };
void disposable(void Function() fn) {
final prev = dispose;
dispose = () {
try {
fn();
} finally {
prev();
}
};
}
That's it. Two closures, one reassignment.
How it works: each call to disposable() wraps the previous dispose in a new function. This builds a chain of closures β a linked list of cleanup actions, implicitly in LIFO order. When dispose() is called, it unwinds: calls the last registered cleanup, then (via finally) the one before it, then the one before that, all the way to the root.
The try/finally ensures that if one cleanup throws, the rest still execute. The first line β dispose = () { dispose = () {}; }; β is double-dispose protection: after the first call, dispose replaces itself with a no-op.
A quick sync demo:
void main() {
print('init');
var a = 'open A';
disposable(() => print('close A'));
print(a);
var b = 'open B';
disposable(() => print('close B'));
print(b);
var c = 'open C';
disposable(() => print('close C'));
print(c);
print('dispose');
dispose();
// Output: close C, close B, close A
}
Why Closures, Not a List?
The obvious alternative is List<VoidCallback> with .reversed.forEach(call). It works, but the closure chain has several advantages:
No container to manage. There's no list to allocate, no index to get wrong, no temptation to .removeAt(i) or .clear() prematurely. The chain is append-only by construction.
Atomic teardown. A list is mutable state that can be observed and modified between iterations. Someone could accidentally clear it mid-dispose, or add to it during dispose. The closure chain is opaque β once built, there's nothing to tamper with.
Composable. You can pass dispose to another function, store it, return it. It's a single void Function() β the simplest possible interface. No need to carry a list reference around.
Natural scoping. Each closure captures exactly the variables it needs from its init scope. No need for a parallel array of "things to dispose" that must stay synchronized with the actual resources.
That said, a List is more inspectable and debuggable. It's a pragmatic trade-off β the closure chain optimizes for correctness and simplicity at the cost of visibility.
Async Variant
The async version looks almost identical:
Future<void> Function() dispose = () async { dispose = () async {}; };
void disposable(Future<void> Function() fn) {
final prev = dispose;
dispose = () async {
try {
await fn();
} finally {
await prev();
}
};
}
Now let's apply it to our database sync scenario:
Future<void> syncDictionaries() async {
final db = await openDatabase('app.db');
disposable(() => db.close());
final token = await fetchAuthToken();
// no cleanup needed for a token β skip disposable
final ws = await WebSocket.connect(url);
disposable(() => ws.close());
final txn = await db.beginTransaction();
disposable(() => txn.rollback()); // rollback on failure
final sub = ws.listen((csv) async {
final rows = parseCsv(csv);
await txn.batchUpsert(rows);
});
disposable(() => sub.cancel());
ws.add(token); // send auth as first packet
await sub.asFuture(); // wait until stream ends
await txn.commit();
// If we got here β success. Replace rollback with no-op:
// (or structure so commit replaces the rollback disposable)
dispose(); // clean up everything in reverse order
}
If WebSocket.connect throws, only db.close() is in the chain β and it will be called. If the stream fails mid-upsert, the transaction rolls back, then the WebSocket closes, then the DB closes. No resource leaks, no matter where it fails.
Cancellation
Dart doesn't have a built-in cancellation token, but we can make one trivially:
class CancellationToken with ChangeNotifier {
bool _isCancelled = false;
bool get isCancelled => _isCancelled;
void cancel() {
if (_isCancelled) return;
_isCancelled = true;
notifyListeners();
}
}
Integrating it with our pattern is straightforward. Between each initialization step, check the token. Before entering the streaming phase, wire up the listener:
Future<void> syncDictionaries(CancellationToken cancel) async {
final db = await openDatabase('app.db');
disposable(() => db.close());
if (cancel.isCancelled) { dispose(); return; }
final token = await fetchAuthToken();
if (cancel.isCancelled) { dispose(); return; }
final ws = await WebSocket.connect(url);
disposable(() => ws.close());
if (cancel.isCancelled) { dispose(); return; }
final txn = await db.beginTransaction();
disposable(() => txn.rollback());
// Wire cancellation to teardown before streaming begins
cancel.addListener(dispose);
final sub = ws.listen((csv) async {
final rows = parseCsv(csv);
await txn.batchUpsert(rows);
});
disposable(() => sub.cancel());
ws.add(token);
await sub.asFuture();
await txn.commit();
// Disconnect cancel listener on success
cancel.removeListener(dispose);
dispose();
}
Now when the user taps "Cancel", dispose() fires immediately, unwinding everything that was initialized up to that point β regardless of which step we're currently on.
Reuse as a Mixin
If you find yourself using this often, extract it:
mixin Disposable {
void Function() dispose = () { dispose = () {}; };
void disposable(void Function() fn) {
final prev = dispose;
dispose = () {
try { fn(); } finally { prev(); }
};
}
}
Or the async variant:
mixin AsyncDisposable {
Future<void> Function() dispose = () async { dispose = () async {}; };
void disposable(Future<void> Function() fn) {
final prev = dispose;
dispose = () async {
try { await fn(); } finally { await prev(); }
};
}
}
Mix it into a State, a service class, a use case β wherever you have multi-step initialization.
Caveats
Lost exceptions. If cleanup A throws and cleanup B also throws, A's exception is lost β finally replaces it. For most Flutter use cases this is acceptable (dispose errors are typically logged, not rethrown). If you need to accumulate errors, collect them into a list and throw an aggregate after the chain completes.
Parallel initialization. This pattern assumes sequential init. If you initialize resources concurrently via Future.wait and one fails while others are still pending, you need synchronization β mutexes or a more sophisticated scope manager. This is a separate topic, but worth keeping in mind: the closure chain is inherently sequential.
Double-dispose is handled. The dispose = () {} reassignment after first invocation ensures subsequent calls are no-ops. This is important when cancel.addListener(dispose) might fire after you've already disposed manually.
Dispose order matters. In 99.9% of cases, resources should be disposed in reverse order of initialization. The closure chain guarantees this structurally β it's not a convention you have to remember, it's how the data structure works.
Summary
The entire pattern is 6 lines of code. It gives you: LIFO disposal order by construction, resilience to exceptions mid-teardown, double-dispose safety, trivial cancellation support, and no external dependencies. It's Go's defer for Dart, built from closures.