Flutter Web SSE Not Working? Fix Buffering + CORS Preflight
Your AI chatbot streams beautifully in FlutterFlow test mode and on Android and iOS. Then you deploy the web build and one of two things happens:
- The reply never appears, and the browser console shows
ClientException: XMLHttpRequest error— the classic Flutter web CORS error, XMLHttpRequest edition. - The reply does appear, but the whole thing lands at once after a long pause — your Flutter web streaming response got buffered, and the token-by-token effect is gone.
These look like one bug ("streaming is broken on web"), but they are two unrelated failures that happen to share a symptom. You can have either one, or both at the same time. This post fixes both with real code.
One symptom, two different bugs
Bug 1: Flutter web buffers the streaming response
On Android and iOS, package:http's default client rides on dart:io, which hands you response bytes as they arrive off the socket. Your response.stream loop fires for every SSE chunk.
On the web, Client() resolves to BrowserClient, which is built on XMLHttpRequest. XHR (as package:http uses it) only delivers the response body after the request completes. This is a long-standing, documented limitation — see dart-lang/http #593 and flutter #86199: the browser client "will only return a response once all the data is available."
So your await for loop over response.stream compiles fine, runs fine, and then receives the entire reply as one giant chunk when the server closes the connection. That's the "one giant blob" failure: the SSE events are technically received on Flutter web in Chrome — just all at once, at the end.
Bug 2: the CORS preflight your endpoint never answers
A streaming chat request is almost always POST with Content-Type: application/json (and often an Authorization header). None of that qualifies as a CORS "simple request," so before Chrome sends your POST, it sends an OPTIONS preflight to the same URL asking permission.
If your streaming endpoint only implements POST — or a proxy in front of it returns 404/405 for OPTIONS — the preflight fails, Chrome never sends the real request, and Flutter surfaces the least helpful error in the framework: ClientException: XMLHttpRequest error.
This is also why mobile worked: CORS is enforced by browsers only. dart:io on Android/iOS never sends a preflight and never checks Access-Control-Allow-Origin. Your endpoint can be completely CORS-ignorant and mobile will never notice. The web build is the first client that cares.
Which one do you have? A 60-second diagnosis
Open Chrome DevTools → Network tab, then send a chat message:
- A red
OPTIONSrequest, or a console error mentioning "blocked by CORS policy" → Bug 2. The stream never started. - The POST shows status 200 but your UI renders nothing until the request finishes → Bug 1. The server is streaming; your client is buffering.
- You fixed CORS and now get the giant blob → congratulations, you had both.
One more trap while you're in there: if the response headers show Content-Encoding: gzip on the SSE response, a compression middleware is buffering server-side too. More on that below.
Fix 1: swap the web client for fetch_client
The browser has supported real response streaming for years — through the Fetch API's ReadableStream, not XHR. The fetch_client package (v1.2.1 at the time of writing) is a drop-in package:http client built on Fetch, so chunks reach your Dart code the moment they arrive.
dependencies:
http: ^1.5.0
fetch_client: ^1.2.1
A nice detail: fetch_client ships an internal shim for non-JS platforms, so you can import it unconditionally and switch on kIsWeb — no conditional imports required. (If you prefer the classic if (dart.library.js_interop) conditional-import pattern, that works too; the shim just makes it optional.)
import 'dart:convert';
import 'package:fetch_client/fetch_client.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;
/// Fetch on web (real streaming), dart:io everywhere else.
http.Client createStreamingClient() {
if (kIsWeb) {
return FetchClient(mode: RequestMode.cors);
}
return http.Client();
}
Stream<String> streamChat(String message, String conversationId) async* {
final client = createStreamingClient();
try {
final request = http.Request(
'POST',
Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
)
..headers['Content-Type'] = 'application/json'
..headers['Accept'] = 'text/event-stream'
..body = jsonEncode({
'message': message,
'conversationId': conversationId,
});
final response = await client.send(request);
if (response.statusCode != 200) {
throw http.ClientException('Stream failed: ${response.statusCode}');
}
final lines = response.stream
.transform(utf8.decoder)
.transform(const LineSplitter());
await for (final line in lines) {
if (!line.startsWith('data:')) continue; // skip comments/blank lines
final data = line.substring(5).trim();
if (data == '[DONE]') break;
yield data;
}
} finally {
client.close();
}
}
This is the same shape you'd use in a FlutterFlow custom action — FlutterFlow compiles your custom Dart for web unchanged, so the kIsWeb branch is exactly what fixes a FlutterFlow web chatbot not streaming while mobile works.
Two related pitfalls worth knowing:
LineSplitterprotects you from network chunk boundaries slicing an SSE frame in half — but if your tokens still glue together or drop, that's a parsing bug, not a transport bug. See our SSE chunk-buffer fix.- Thinking of switching to Dio instead? Its default web adapter is also
XMLHttpRequest-based, so it inherits the same buffering problem in the browser. We covered Dio's streaming quirks in FlutterFlow chatbot won't stream with Dio.
Fix 2: answer the preflight, and un-buffer the server
If you call a streaming API through your own backend (common for auth, logging, or prompt injection of user context), that backend must handle two requests per chat message:
OPTIONS /chat/stream— the preflight. Answer fast, with the allow-headers Chrome asked for.POST /chat/stream— the actual stream. The CORS header must appear here too; a correct preflight alone is not enough.
Here's a complete Express example:
const ALLOWED_ORIGINS = new Set([
'https://app.yourdomain.com', // your published web app
'http://localhost:5000', // local flutter run -d chrome
]);
function corsOrigin(req) {
const origin = req.headers.origin;
return ALLOWED_ORIGINS.has(origin) ? origin : '';
}
app.options('/chat/stream', (req, res) => {
res.set({
'Access-Control-Allow-Origin': corsOrigin(req),
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400', // cache the preflight for a day
});
res.status(204).end();
});
app.post('/chat/stream', (req, res) => {
res.set({
'Access-Control-Allow-Origin': corsOrigin(req), // required here as well
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // tells nginx not to buffer this response
});
res.flushHeaders();
// ...write `data: <token>\n\n` frames as they arrive, then end.
});
Server-side rules that bite people in production:
- Skip compression for the SSE route. gzip middleware waits for enough bytes to compress, which re-buffers your stream. Exclude
text/event-streamresponses. - Don't set
Content-Length. A stream has no known length; setting one makes proxies wait for it. - Wildcards have limits.
Access-Control-Allow-Origin: *is rejected by browsers when the request includes credentials (cookies). Echo a validated origin instead, as above. - Every proxy in the chain counts. nginx, Cloudflare, or an API gateway can each buffer independently.
X-Accel-Buffering: nohandles nginx; check your CDN's docs for its equivalent.
FlutterFlow note: test mode and your published domain are different origins
FlutterFlow test mode runs on FlutterFlow's own domain, while your published app runs on yours — so a CORS allowlist that made test mode work will fail after publishing (and vice versa). Allow both origins. If test mode itself is where the CORS wall first appeared, we wrote up that scenario separately in FlutterFlow streaming CORS in test mode.
The rest of the streaming cluster
Web has its own failure modes, but mobile isn't maintenance-free either: the same SSE stream that survives Chrome will die when a phone user backgrounds the app mid-reply. Fix Flutter SSE streams that die on backgrounding covers reconnect-and-resume for that case.
Ship a stream that works on every platform
If you'd rather not run your own SSE infrastructure at all, WidgetChat's chat endpoint (POST https://api.widgetchat.app/v1/chat/stream) streams replies token-by-token over SSE, and the widget integrates into Flutter and FlutterFlow through a plain HTTP client or custom action — no proprietary SDK. Pair it with the fetch_client pattern above and the same code streams on Android, iOS, and the web build.
Try WidgetChat free and get a streaming AI support chatbot answering from your own content today.



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