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:
- Abort the in-flight request so the server stops generating.
- Cancel the
StreamSubscriptionso no callback fires into a dead widget. close()thehttp.Clientso the connection pool is released.- Guard every
setStatewithmountedfor 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:
- Sit on the home screen, press Take snapshot.
- Push the chat route, send a message, and pop the route while tokens are still arriving.
- Repeat that push/stream/pop cycle five times.
- Press the GC button, then take a second snapshot and diff it against the first.
- Filter for
_ChatScreenStateandStreamingChatController.
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+abortTriggerso the server stops generating. close()thehttp.Clientyou created.- Prefer
ListenableBuilderoversetState; guard the remaining awaits withmounted. - Handle tab switches and
reassemble(), not justdispose(). - 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.





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