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

← Back to Blog
Flutter Web SSE Not Streaming? Fix the Buffered Chat

Flutter Web SSE Not Streaming? Fix the Buffered Chat

flutter-webssestreamingai-chathttp

Flutter Web SSE Not Streaming? Fix the Buffered Chat

Your AI support widget types out answers beautifully on iOS and Android. You build for web, ask the same question, stare at a spinner for nine seconds, and then the entire answer appears in one block.

The backend is almost certainly innocent. The same POST https://api.widgetchat.app/v1/chat/stream endpoint is emitting data: frames the whole time — something between the socket and your StreamBuilder is holding them. There are exactly two layers that do this, and you can rule one out in thirty seconds.

Layer 1: prove the bytes leave the server early

Before touching Dart, check the wire with curl -N (-N disables curl's own output buffering):

curl -N -i -X POST https://api.widgetchat.app/v1/chat/stream \
  -H "Authorization: Bearer $WIDGETCHAT_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: text/event-stream' \
  -d '{"message":"what do your plans include?"}'

If tokens trickle out of curl, the network path is fine and your problem is Dart-side — skip to layer 2. If curl also pauses and then vomits everything, read the response headers it just printed:

  • transfer-encoding: chunked should be present and content-length absent. A content-length means something upstream collected the whole body before forwarding it.
  • x-accel-buffering: no should be present if nginx (or anything nginx-derived) is in the path.

This matters because curl from your laptop traverses the same CDN and reverse proxy as Chrome does. Run it against your origin directly too — if origin streams and the public hostname doesn't, you have found your buffer.

The proxy fixes worth knowing

For nginx, buffering is on by default and will happily sit on an SSE stream:

location /v1/chat/stream {
    proxy_pass              http://app_upstream;
    proxy_http_version      1.1;
    proxy_set_header        Connection '';
    proxy_buffering         off;
    proxy_cache             off;
    chunked_transfer_encoding off;
    proxy_read_timeout      1h;
}

Cloudflare is the other repeat offender. Users have reported proxied (orange-cloud) hostnames holding text/event-stream responses until roughly 100 KB has accumulated, even with response buffering disabled — which is why a short chat reply lands all at once and a very long one appears to half-work. The practical mitigation is to emit X-Accel-Buffering: no from your origin, and to send a keep-alive comment frame (: ping\n\n) every 20–30 seconds so an idle model call does not trip the 100-second timeout.

If curl streams, none of this is your problem. Move on.

Layer 2: the Dart client — and which version pub actually resolved

This is where the mobile-vs-web asymmetry comes from. On iOS and Android, http.Client() gives you an IOClient built on dart:io, which has always delivered response bytes as they arrive. On web you get BrowserClient, and for years that class was built on XMLHttpRequest — it could not surface a partial body, so it handed you everything only once the response completed. That is dart-lang/http#593, Flutter Web buffering streamed/chunked data, filed in 2021 and describing your exact symptom.

The good news: http 1.3.0 switched BrowserClient to the Fetch API instead of XMLHttpRequest. The current implementation reads Response.body (a ReadableStream) through a reader and republishes it as a Dart stream, so responses genuinely stream. Its own doc comment is blunt about the remaining limit: responses are streamed, requests are not.

So step one is not a code change — it is checking what your lockfile resolved:

grep -A3 '^  http:' pubspec.lock

A ^1.1.0 constraint in your pubspec does not mean you got 1.3.0+. A transitive dependency pinning an older http will silently drag you back to the XHR client, and pub will not warn you because nothing is broken — only slow. http 1.3.0 and later require Dart SDK 3.4+; 1.6.0 is current and includes a web fix for cancelling subscriptions that are waiting on the next chunk.

The second trap: http.post() can never stream

Even on a fetch-backed BrowserClient, this buffers by definition:

final res = await http.post(url, body: payload); // waits for the LAST byte
for (final line in res.body.split('\n')) { ... }  // then replays instantly

http.Response is a materialised body. To stream you must go through Client.send() and consume StreamedResponse.stream as it arrives. Plenty of "flutter web sse not streaming" reports are really this: mobile felt fast enough that nobody noticed the top-level helper was buffering there too.

A streaming SSE reader that works on all three platforms

This uses only package:http — no conditional imports, no web-only code — and works on mobile and on web once you are on http 1.3.0+.

import 'dart:convert';
import 'package:http/http.dart' as http;

/// Yields each `data:` payload from WidgetChat's streaming chat endpoint.
Stream<String> streamReply({
  required String message,
  required String projectKey,
  String? conversationId,
}) async* {
  final client = http.Client();
  final request = http.Request(
    'POST',
    Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
  )
    ..headers.addAll({
      'Authorization': 'Bearer $projectKey',
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream',
    })
    ..body = jsonEncode({
      'message': message,
      if (conversationId != null) 'conversation_id': conversationId,
    });

  try {
    final response = await client.send(request);
    if (response.statusCode != 200) {
      throw http.ClientException(
        'chat/stream returned ${response.statusCode}',
        request.url,
      );
    }

    // utf8.decoder as a *stream transformer* is stateful: it correctly
    // reassembles a multi-byte character split across two chunks.
    // Calling utf8.decode() per chunk instead will mangle emoji mid-stream.
    var buffer = '';
    await for (final text in response.stream.transform(utf8.decoder)) {
      buffer += text.replaceAll('\r\n', '\n');

      // SSE frames are separated by a blank line.
      var split = buffer.indexOf('\n\n');
      while (split != -1) {
        final frame = buffer.substring(0, split);
        buffer = buffer.substring(split + 2);

        for (final line in const LineSplitter().convert(frame)) {
          if (!line.startsWith('data:')) continue; // skip `:` comments, ids
          final data = line.substring(5).trimLeft();
          if (data == '[DONE]') return;
          yield data;
        }
        split = buffer.indexOf('\n\n');
      }
    }
  } finally {
    client.close();
  }
}

Wiring it into a chat bubble is then ordinary Flutter — accumulate into a StringBuffer and rebuild:

final _draft = StringBuffer();
late StreamSubscription<String> _sub;

void _send(String text) {
  _draft.clear();
  _sub = streamReply(message: text, projectKey: kWidgetChatKey).listen(
    (payload) => setState(() => _draft.write(_tokenFrom(payload))),
    onDone: () => setState(_commitMessage),
  );
}

_tokenFrom is where you decode whatever your data: payload carries — raw text, or a small JSON object. Cancel _sub in dispose(); on web that aborts the underlying fetch.

Escape hatch: your own fetch client

If a transitive dependency pins you below http 1.3.0, or you need fetch options the client does not expose (cache mode, credentials, a custom AbortSignal), drop to package:web behind a conditional import so mobile never compiles it:

// lib/src/stream_transport.dart
export 'stream_transport_io.dart'
    if (dart.library.js_interop) 'stream_transport_web.dart';
// lib/src/stream_transport_web.dart
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:web/web.dart' as web;

Stream<Uint8List> postByteStream(
  Uri url, {
  required Map<String, String> headers,
  required String body,
}) async* {
  final res = await web.window
      .fetch(
        url.toString().toJS,
        web.RequestInit(
          method: 'POST',
          headers: headers.jsify()! as JSObject,
          body: body.toJS,
          cache: 'no-store', // never replay a cached body for a stream
        ),
      )
      .toDart;

  if (res.status != 200) {
    throw StateError('chat/stream returned ${res.status}');
  }

  final reader =
      res.body!.getReader() as web.ReadableStreamDefaultReader;
  try {
    while (true) {
      final chunk = await reader.read().toDart;
      if (chunk.done) break;
      yield (chunk.value! as JSUint8Array).toDart;
    }
  } finally {
    reader.releaseLock();
  }
}

Feed that byte stream into the same frame parser above. If you would rather not hand-roll it, package:fetch_client (1.2.1 at time of writing) gives you a drop-in FetchClient implements http.Client with streamed responses, cancellation and redirect metadata — package:http's own README points at it for web.

The order to debug in

  1. curl -N the endpoint. Bytes early? Backend and proxies are fine.
  2. grep http pubspec.lock. Below 1.3.0, you are on the XHR client — upgrade.
  3. Using http.post() or reading response.body? That buffers everywhere. Switch to Client.send().
  4. Still buffered on web only? Swap in fetch_client or the package:web reader above.
  5. Buffered for everyone past ~100 KB? Look at Cloudflare and X-Accel-Buffering.

WidgetChat's /v1/chat/stream endpoint emits token-by-token data: SSE and integrates through a plain HTTP client or a FlutterFlow custom action — no proprietary SDK — so the client you pick is entirely yours. If you would rather your users talk than type, live voice chat is shipped too: the same embedded widget does real-time speech-to-speech with barge-in, live captions and on-screen product cards, on iOS, Android and web, with provider keys kept server-side.

Try WidgetChat free and get a streaming AI support assistant into your Flutter or FlutterFlow app today.

dart-lang/http#593 — the original 'Flutter Web buffering streamed/chunked data' report

package:fetch_client on pub.dev, the fetch-based drop-in replacement for BrowserClient

The http changelog entry for 1.3.0: BrowserClient switched to the Fetch API

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!