Flutter Web WebSocket Support: wss, Proxies, and Browser Limits
Flutter web WebSocket realities: wss mixed content, per-origin connection caps, Nginx upgrade headers, subprotocol auth, heartbeats, and when SSE wins.
Published on • November 12, 2026
AI Assistant

The Dart in flutter-websockets-real-time-apps runs unchanged in the browser, but the transport underneath it changes completely. On Android and iOS you own a raw socket; on the web the browser owns it, enforces an origin policy, caps how many it will keep open, and kills idle connections on its own schedule. web_socket_channel v3.0.3 gives you the same StreamChannel surface either way, so the work is not learning a new API, it is learning the rules the browser applies behind it.
Same API, browser-backed transport
WebSocketChannel.connect picks the platform implementation for you, and its static signature is deliberately small: connect(Uri uri, {Iterable<String>? protocols}). Everything else you already know survives the port:
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
final channel = WebSocketChannel.connect(
Uri.parse('wss://api.example.com/live'),
protocols: ['v1.live.json'], // sent as Sec-WebSocket-Protocol
);
await channel.ready; // connection established, safe to send
print(channel.protocol); // subprotocol the server picked
channel.stream.listen((message) => channel.sink.add('ack'));
channel.sink.close(status.goingAway);
On web the underlying object wraps the browser’s WebSocket, so stream, sink, ready, protocol, closeCode and closeReason all keep their meanings while the browser enforces the handshake rules.
wss is not optional on an https page
An https page must open wss://. MDN’s mixed content rule is that upgradable content is limited to images, CSS images, audio and video, and that “all mixed content that is not upgradable” is blocked. A WebSocket is not on that upgradable list, so ws:// from a secure page is blocked outright rather than quietly upgraded. The practical rule: develop against ws://localhost if you like, but never let a production URL slip into a build that ships over TLS.
Connection limits per origin
MDN notes that because an HTTP/1.1 connection serializes requests, browsers open several connections per domain, “now increased to a more common use of 6 parallel connections.” A long-lived WebSocket occupies one of those six for as long as it lives, so a chat screen plus three polling fetches plus an image-heavy page can exhaust an HTTP/1.1 origin. HTTP/2 multiplexes ordinary requests over a single connection, which relieves the pressure on the rest of the page, but the socket itself is still a dedicated upgrade. Design for one shared channel per origin, not one per screen.
Getting the upgrade through a proxy
Upgrade and Connection are hop-by-hop headers, so nginx does not forward them unless you say so explicitly. Since 1.3.13 nginx supports the tunnel mode needed for a 101 Switching Protocols response:
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /live/ {
proxy_pass http://backend;
# proxy_http_version 1.1; # only needed before nginx 1.29.7
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
}
Two timeouts decide whether the connection survives. nginx closes the link if the proxied server sends nothing within 60 seconds by default, which you raise with proxy_read_timeout; the alternative the docs suggest is having the server send WebSocket ping frames periodically, which resets the timeout and doubles as a liveness check. Cloud load balancers and CDNs have equivalent idle timeouts, so treat a 30-45 second server-side ping as the portable default.
Authenticating a browser socket
You cannot add an Authorization header: MDN documents Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol and Sec-WebSocket-Extensions as headers the browser adds automatically, and there is no slot for your own. Two options remain:
- Subprotocol handshake. Put the token in the
protocolslist, have the server select a protocol only on success, and read it back fromchannel.protocol. Tokens end up in server and proxy logs, so keep them short-lived. - First-message handshake. Connect anonymously, then send
{type: 'auth', token: ...}as the first frame and treat anauth_failedclose code as a terminal error. This keeps credentials out of headers entirely.
Either way, verify auth state after await channel.ready, not before, and check channel.closeCode once the stream ends to distinguish a rejected handshake from a dropped network.
Heartbeats and reconnect
Browsers and their proxies reap idle sockets, and a Flutter web tab that sits in the background is exactly that. Because web_socket_channel deliberately does not reconnect, your service layer owns the loop: send an application ping every 30-45 seconds, treat a silent interval as dead, and reconnect with capped exponential backoff. The reconnect service in flutter-websockets-real-time-apps already has the right shape; on web, add the heartbeat timer and you are done.
Reading why the socket died
Once the stream ends, the browser hands you back a close code and reason, and on web they are the only diagnostic you get:
channel.stream.listen(
_handleMessage,
onError: (Object e) => _report(e), // WebSocketChannelException
onDone: () {
final code = channel.closeCode; // 1000 clean, 1006 abnormal
final reason = channel.closeReason;
_telemetry.record(code, reason);
if (code != status.normalClosure) _scheduleReconnect();
},
);
A 1006 with no reason almost always means an intermediary dropped the link rather than your server closing it, which is the cue to go looking at proxy idle timeouts. A 1008 with a reason usually means the server rejected you, which is the cue to check the auth handshake above.
Backpressure is not free
MDN is blunt about it: the WebSocket interface does not support backpressure, so messages that arrive faster than the app consumes them either fill device memory or burn CPU trying to keep up, or both. A Flutter web tab restored from the background is the classic trigger, because the browser has been holding frames the whole time. Keep the listener cheap, batch on the server, and drop stale ticks rather than queueing them. If you genuinely need flow control, MDN points at WebTransport, which supports backpressure, unidirectional streams and datagrams.
When Server-Sent Events are the better answer
If traffic is purely server to client, EventSource wins on web: it is plain HTTP, so existing auth headers, proxies and load balancers keep working, and the browser performs reconnection for you instead of your Dart loop. Use WebSockets when the client must also send. For read-heavy data with occasional writes, a normal http.get on an interval is still the honest answer.
Takeaway: On Flutter web, ship wss:// only, multiplex one socket per origin, forward the Upgrade/Connection headers at every proxy, authenticate via subprotocol or first message since custom headers are impossible, and keep the socket warm with heartbeats plus a reconnect loop you own.
Sources
- https://pub.dev/packages/web_socket_channel
- https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
- https://nginx.org/en/docs/http/websocket.html
- https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Connection_management_in_HTTP_1.x
- https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel-class.html