Dart Flutter Article

Safe Resource Cleanup with Closure Chains

Dart has no defer, no RAII, no scope guards. When multi-step async initialization fails midway, you need to unwind only what was set up β€” in reverse order, crash-safe. Here's a 6-line closure chain pattern that gives you transactional init, safe teardown, and cancellation support for free.
Plague Fox 5 min read
Safe Resource Cleanup with Closure Chains

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.

Share
Comments
More from Plague Fox

Plague Fox

Engineer by day, fox by night. Talks about Flutter & Dart.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Plague Fox.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.