widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Flutter AI Chat Memory Leak: Cancel SSE in dispose()

Flutter AI Chat Memory Leak: Cancel SSE in dispose()

flutterssememory-leakstreamingdispose

Flutter AI Chat Memory Leak: Cancel SSE in dispose()

Your chat screen streams fine in testing. Then a user taps back while the assistant is still typing, and one of these lands in your dashboard:

  • setState() called after dispose() in Crashlytics
  • memory climbing a few MB every time the chat route is pushed and popped
  • streams still open server-side, still generating tokens, still billed

All three have one cause: the SSE response from POST https://api.widgetchat.app/v1/chat/stream outlives the widget that started it. And the advice you will find most often — cancel the StreamSubscription in dispose() — is only about a third of the teardown.

The leaky version

This is the shape almost every first AI chat screen takes:

class _ChatScreenState extends State<ChatScreen> {
  final _client = http.Client();
  String _reply = '';

  Future<void> _send(String text) async {
    final req = http.Request('POST', Uri.parse('https://api.widgetchat.app/v1/chat/stream'))
      ..headers['content-type'] = 'application/json'
      ..body = jsonEncode({'message': text});

    final res = await _client.send(req);
    res.stream
        .transform(utf8.decoder)
        .transform(const LineSplitter())
        .listen((line) {
      setState(() => _reply += line); // boom, eventually
    });
  }
}

Three defects: the subscription is never stored so it can never be cancelled, the client is never closed, and — the one that surprises people — even cancelling that subscription does not reliably tell the server to stop.

Why cancelling the subscription is not enough

In package:http, the bytes you are listening to come from an HttpClientResponse that your code owns. The comment in IOClient's own source is blunt about it: if you stop reading without destroying the response, the connection stays open waiting for a body that will never be read.

Cancelling a StreamSubscription is a local operation on a Dart stream. It stops your callback from firing. It does not, by itself, mean a FIN went down the socket. http 1.5.0 fixed a bug in IOClient response-stream cancellation and 1.6.0 fixed web cancellations, so modern versions behave much better — but relying on cancellation as your abort signal is still indirect. For a token stream, an open connection is not just an idle socket: the model keeps generating and the meter keeps running.

The teardown you actually want has four steps:

  1. Abort the in-flight request so the server stops generating.
  2. Cancel the StreamSubscription so no callback fires into a dead widget.
  3. close() the http.Client so the connection pool is released.
  4. Guard every setState with mounted for the awaits you cannot cancel.

Abort is a real API now (http 1.5.0+)

http 1.5.0 added request abortion and 1.6.0 is current. You create an AbortableRequest with an abortTrigger future; completing that future aborts the request, and the response future or response stream surfaces a RequestAbortedException. It is supported by IOClient, BrowserClient and RetryClient — so iOS, Android, desktop and web — and only works through Client.send / BaseRequest.send, which is exactly what SSE requires anyway.

dependencies:
  http: ^1.6.0

A StreamingChatController that disposes correctly

Put the lifecycle in one object and the screen stops being the place bugs live:

import 'dart:async';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;

class StreamingChatController extends ChangeNotifier {
  StreamingChatController({required this.projectKey, http.Client? client})
      : _client = client ?? http.Client();

  final String projectKey;
  final http.Client _client;

  StreamSubscription<String>? _sub;
  Completer<void>? _abort; // fires -> server stops generating
  Completer<void>? _done;  // completes when this turn is over

  final StringBuffer _buffer = StringBuffer();
  bool _disposed = false;
  bool _streaming = false;

  String get reply => _buffer.toString();
  bool get isStreaming => _streaming;

  Future<void> send(String message) async {
    await stop(); // never run two streams into the same buffer

    final abort = Completer<void>();
    final done = Completer<void>();
    _abort = abort;
    _done = done;
    _buffer.clear();
    _streaming = true;
    _notify();

    final request = http.AbortableRequest(
      'POST',
      Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
      abortTrigger: abort.future,
    )
      ..headers['authorization'] = 'Bearer $projectKey'
      ..headers['content-type'] = 'application/json'
      ..headers['accept'] = 'text/event-stream'
      ..body = jsonEncode({'message': message});

    try {
      final response = await _client.send(request);

      _sub = response.stream
          .transform(utf8.decoder)
          .transform(const LineSplitter())
          .listen(
        _onLine,
        onError: (Object e, StackTrace s) {
          if (!done.isCompleted) done.completeError(e, s);
        },
        onDone: () {
          if (!done.isCompleted) done.complete();
        },
        cancelOnError: true,
      );

      await done.future;
    } on http.RequestAbortedException {
      // The user left mid-stream. Expected, not an error.
    } finally {
      _streaming = false;
      _notify();
    }
  }

  void _onLine(String line) {
    if (!line.startsWith('data:')) return; // blank lines and comments
    final payload = line.substring(5).trim();
    if (payload.isEmpty) return;
    _buffer.write(_textOf(payload));
    _notify();
  }

  // Log one raw frame the first time you integrate and map the field you
  // actually receive; this falls back to the raw payload for plain-text frames.
  String _textOf(String payload) {
    try {
      final decoded = jsonDecode(payload);
      if (decoded is Map && decoded['text'] is String) return decoded['text'] as String;
    } on FormatException {
      // not JSON
    }
    return payload;
  }

  /// Stops the current turn: aborts the POST, then cancels the subscription.
  Future<void> stop() async {
    final abort = _abort;
    _abort = null;
    if (abort != null && !abort.isCompleted) abort.complete();

    // A cancelled subscription never fires onDone, so release send() by hand.
    final done = _done;
    _done = null;
    if (done != null && !done.isCompleted) done.complete();

    final sub = _sub;
    _sub = null;
    await sub?.cancel();
  }

  void _notify() {
    if (!_disposed) notifyListeners();
  }

  @override
  void dispose() {
    _disposed = true;
    // dispose() is synchronous but cancel() is async (flutter#125849),
    // so fire and forget, and close the client once teardown lands.
    unawaited(stop().whenComplete(_client.close));
    super.dispose();
  }
}

The ordering in stop() matters. Abort first so the server hears about it, then cancel locally. Reversed, you cancel the stream and the abort trigger has nothing left to interrupt.

Wiring it into the screen

class _ChatScreenState extends State<ChatScreen> {
  late final StreamingChatController _controller =
      StreamingChatController(projectKey: widget.projectKey);

  @override
  void dispose() {
    _controller.dispose(); // aborts POST, cancels sub, closes client
    super.dispose();
  }

  Future<void> _send(String text) async {
    await _controller.send(text);
    if (!mounted) return; // this await can outlive the State
    _scrollToBottom();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      animation: _controller,
      builder: (context, _) => ChatTranscript(
        text: _controller.reply,
        streaming: _controller.isStreaming,
      ),
    );
  }
}

ListenableBuilder removes the setState call entirely, which removes the class of crash along with it. The mounted check stays for the plain awaits.

The two exits that are not dispose

Tab switch. With TabBarView, IndexedStack or AutomaticKeepAliveClientMixin, the State is kept alive and dispose() never runs — so the stream keeps billing while the user reads another tab. Hook the tab controller instead:

_tabs.addListener(() {
  if (_tabs.index != chatTabIndex) _controller.stop();
});

Hot reload. State survives hot reload, so an in-flight stream from before the edit keeps writing into a buffer your new code no longer expects. One override fixes it:

@override
void reassemble() {
  super.reassemble();
  _controller.stop();
}

Prove it with DevTools

Don't trust the fix, measure it. In DevTools, open Memory and the Diff Snapshots tab:

  1. Sit on the home screen, press Take snapshot.
  2. Push the chat route, send a message, and pop the route while tokens are still arriving.
  3. Repeat that push/stream/pop cycle five times.
  4. Press the GC button, then take a second snapshot and diff it against the first.
  5. Filter for _ChatScreenState and StreamingChatController.

Before the fix you will see the delta climb by one instance per cycle, and the retaining path runs from the subscription through the HTTP response back to your State. After the fix the delta is zero and the retaining path is gone. That retaining path is the actual evidence — instance counts alone can lag behind GC.

Lock it in with leak_tracker

Flutter ships leak tracking for widget tests. Add leak_tracker_flutter_testing as a dev dependency and turn it on in test/flutter_test_config.dart:

import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  LeakTesting.enable();
  LeakTesting.settings = LeakTesting.settings.withIgnored(createdByTestHelpers: true);
  await testMain();
}

Now a testWidgets case that pumps the chat screen and then pumps an empty SizedBox fails the build if the controller was never disposed — which is far cheaper than finding out from a bill.

The checklist

  • Store the StreamSubscription; cancel it in teardown.
  • Use AbortableRequest + abortTrigger so the server stops generating.
  • close() the http.Client you created.
  • Prefer ListenableBuilder over setState; guard the remaining awaits with mounted.
  • Handle tab switches and reassemble(), not just dispose().
  • Diff two heap snapshots and confirm the retaining path is gone.

Try WidgetChat free

WidgetChat is an AI support chatbot you embed in Flutter and FlutterFlow apps: token-by-token SSE streaming from POST /v1/chat/stream through a plain HTTP client or a custom action, no proprietary SDK to fight with — and now live voice chat, where a tap on the mic turns the same widget into a real-time speech-to-speech call with barge-in and live captions. Try WidgetChat free and wire it into your app this afternoon.

package:http on pub.dev — version 1.6.0, which includes the abort API used here

Flutter DevTools memory docs — the Diff Snapshots workflow used to prove the leak is gone

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!