Fix Flutter 'Connection closed while receiving data' in SSE
Your chatbot reply is streaming in nicely, three sentences in, and then it stops. The console shows:
ClientException: Connection closed while receiving data, uri=https://api.widgetchat.app/v1/chat/stream
On mobile this is normal. The phone moves from Wi-Fi to cellular, goes into a lift, or a carrier proxy drops an idle socket. The bug is how the app reacts, not the network. Usually the half-written bubble disappears, a generic error snackbar shows up, and when the user resends, their message appears twice.
This post shows how to handle the drop properly in a Flutter app that streams AI chat replies over Server-Sent Events with the http package:
- tell a dropped stream apart from one that actually finished
- keep the tokens already on screen
- show a "reply interrupted" state with a retry button and capped exponential backoff
- retry without posting the user's message twice
The examples call WidgetChat's streaming endpoint, POST https://api.widgetchat.app/v1/chat/stream, which returns the reply token by token as SSE data: lines. The same pattern works for any SSE chat backend.
Where the error comes from
In the http package, IOClient (the default client on Android and iOS) gives you a StreamedResponse whose body is a byte stream. If the underlying socket closes before the body is complete, dart:io raises an HttpException, and IOClient wraps it in a ClientException that is delivered as an error on response.stream, not from client.send().
That detail matters. Your await client.send(request) succeeded, you got a 200 and headers, and the failure only shows up later inside your await for loop. A try/catch around send() alone will not catch it.
A drop and a finish can look alike
A flutter sse stream can end in three ways:
- Clean finish. The server sends its last event and closes the connection.
- Hard drop. The stream throws
ClientException: Connection closed while receiving data(or aSocketException, or your ownTimeoutException). - Silent drop. The stream just ends with no error, because a proxy closed the connection cleanly halfway through the reply.
Case 3 is the trap. If you treat "stream done" as "reply done", a truncated answer gets saved as if it were complete. The fix: count a reply as finished only when you saw the stream's explicit end signal. Anything else counts as interrupted.
Check what your stream sends as its last event. Many OpenAI-style streams send data: [DONE]. Match whatever your backend sends and keep that check in one function so it is easy to change.
Model the reply as a state, not a string
Give each assistant bubble a status so the UI can show partial text with a retry option:
enum ReplyStatus { streaming, done, interrupted, failed }
class AssistantReply {
AssistantReply({required this.forUserMessageId});
final String forUserMessageId;
final StringBuffer text = StringBuffer();
ReplyStatus status = ReplyStatus.streaming;
int attempt = 0;
}
forUserMessageId is the key to avoiding duplicates. A retry re-runs the request for an existing user message. It never adds a new one.
Stream the reply and classify how it ended
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
final _endpoint = Uri.parse('https://api.widgetchat.app/v1/chat/stream');
// Match this to the end marker your stream actually sends.
bool isEndEvent(String data) => data == '[DONE]';
enum StreamOutcome { finished, interrupted }
Future<StreamOutcome> streamReply({
required http.Client client,
required Map<String, String> headers,
required Map<String, dynamic> body,
required void Function(String token) onToken,
}) async {
final request = http.Request('POST', _endpoint)
..headers.addAll({
...headers,
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
})
..body = jsonEncode(body);
final response = await client.send(request);
if (response.statusCode != 200) {
throw HttpStatusError(response.statusCode);
}
var sawEnd = false;
try {
final lines = response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
// No bytes for 30s on a live reply means the socket is dead.
.timeout(const Duration(seconds: 30));
await for (final line in lines) {
if (!line.startsWith('data:')) continue;
final data = line.substring(5).trimLeft();
if (isEndEvent(data)) {
sawEnd = true;
break;
}
onToken(data);
}
} on http.ClientException {
return StreamOutcome.interrupted;
} on TimeoutException {
return StreamOutcome.interrupted;
}
return sawEnd ? StreamOutcome.finished : StreamOutcome.interrupted;
}
class HttpStatusError implements Exception {
HttpStatusError(this.statusCode);
final int statusCode;
bool get retryable => statusCode == 429 || statusCode >= 500;
}
A few notes:
utf8.decoderis a chunked converter, so a multi-byte character split across two TCP packets decodes correctly. Decoding each chunk withutf8.decodeyourself will break emoji and accented text exactly when the network is flaky.- How you turn
datainto display text depends on the payload your stream sends. If each event is JSON, decode it inonToken. .timeout()on the line stream catches the socket that never closes but never sends either. That is the same failurereceiveTimeoutcovers in Dio; see our Dio receiveTimeout post on the WidgetChat blog if you use Dio.- The last line,
sawEnd ? ... : interrupted, is what catches the silent drop.
Retry with capped exponential backoff
This is a flutter streaming chat retry, not a resume. SSE has a Last-Event-ID reconnect mechanism, but it only helps if the server tracks event ids and can continue a reply, so don't assume that for a generated answer. The safe approach is to request the reply again for the same user message.
import 'dart:math';
final _rng = Random();
Duration backoff(int attempt) {
const base = Duration(seconds: 1);
const cap = Duration(seconds: 16);
final exp = base * pow(2, attempt).toInt();
final capped = exp > cap ? cap : exp;
// Full jitter so many phones reconnecting after the same outage spread out.
return Duration(milliseconds: _rng.nextInt(capped.inMilliseconds + 1));
}
const maxAutoRetries = 3;
Future<void> runReply(ChatController c, AssistantReply reply) async {
while (true) {
reply.status = ReplyStatus.streaming;
final retryBuffer = StringBuffer();
var firstToken = true;
c.notify();
try {
final outcome = await streamReply(
client: c.client,
headers: c.authHeaders,
body: c.buildBody(reply.forUserMessageId),
onToken: (t) {
if (reply.attempt > 0 && firstToken) {
// Keep the old partial text visible until the new reply starts.
reply.text.clear();
firstToken = false;
}
reply.text.write(t);
retryBuffer.write(t);
c.notify();
},
);
if (outcome == StreamOutcome.finished) {
reply.status = ReplyStatus.done;
c.notify();
return;
}
} on HttpStatusError catch (e) {
if (!e.retryable) {
reply.status = ReplyStatus.failed;
c.notify();
return;
}
}
if (reply.attempt >= maxAutoRetries) {
reply.status = ReplyStatus.interrupted;
c.notify();
return;
}
await Future.delayed(backoff(reply.attempt));
reply.attempt++;
}
}
Why these choices:
- Capped and jittered. Without a cap, attempt 6 waits a minute while the user stares at a spinner. Without jitter, every client hits the server at the same moment after an outage.
- Only retry what can succeed. A 401 or 400 will fail the same way every time. Retry on drops, timeouts, 429 and 5xx.
- Partial text stays until replaced. The user keeps reading what they had. It only clears once the new reply sends its first token, so the bubble never flashes empty.
- Stop after a few tries and hand control to the user. That is the
interruptedstate.
The "reply interrupted" bubble
Widget buildReply(AssistantReply r, VoidCallback onRetry) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.text.toString()),
if (r.status == ReplyStatus.streaming && r.attempt > 0)
const Text('Reconnecting...', style: TextStyle(fontSize: 12)),
if (r.status == ReplyStatus.interrupted)
Row(children: [
const Icon(Icons.wifi_off, size: 16),
const SizedBox(width: 6),
const Text('Reply interrupted'),
TextButton(onPressed: onRetry, child: const Text('Retry')),
]),
],
);
}
The retry button resets attempt to 0 and calls runReply with the same AssistantReply. It does not call your send-message function, so the user bubble is not duplicated in the list.
Don't duplicate the user's message
Duplicates come from two places.
Client side. If retry goes through the same code path as "send", it appends a new user bubble. Keep two separate functions: sendMessage(text) creates the user message and its AssistantReply, and retryReply(reply) only re-runs the stream.
Request side. If your request body carries the conversation history, build it from your message list, which has exactly one copy of the user's message. Don't append the latest text again on retry, and don't include the interrupted partial assistant text in the history, or the model will treat the half answer as something it already said.
When retrying isn't the answer
Some drops are not glitches:
- The app went to the background. iOS and Android suspend or kill sockets soon after the app leaves the foreground. Retrying in the background just fails again. Pause, then retry when the app resumes; our post on background cut-offs on the WidgetChat blog covers the lifecycle hooks.
- The device is offline. Backoff against a dead radio drains battery for nothing. Hold the request until connectivity returns; see the offline queue post on the WidgetChat blog.
In both cases the interrupted state shown above is the right thing to show while you wait.
Checklist
- Catch
http.ClientExceptioninside theawait for, not just aroundsend(). - Count a reply as done only when you see the explicit end event.
- Add a read timeout to the line stream.
- Decode with
utf8.decoder, not per-chunkutf8.decode. - Backoff: 1s base, 16s cap, full jitter, 3 auto retries, then a manual Retry.
- Retry re-runs the reply for an existing user message. It never resends the message.
Try WidgetChat free
WidgetChat adds an AI customer-support chatbot to Flutter and FlutterFlow apps that answers from your own content, with streaming replies over SSE from POST https://api.widgetchat.app/v1/chat/stream. You can call it from a plain HTTP client or a FlutterFlow custom action, no proprietary SDK needed, so the retry code above drops straight in. Try WidgetChat free.





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