web_socket_channel package, including the parts most tutorials skip: reconnecting when the connection drops, and keeping messages in order.
What is a WebSocket, exactly?
A WebSocket is a communication protocol that provides a full-duplex channel over a single TCP connection. “Full-duplex” is the important word: both sides, client and server, can send data to each other at any time, independently, over the same open connection. It was standardised by the IETF as RFC 6455 in 2011, and the browser-facing API is standardised separately by the W3C.
Compare that to the normal web request-response pattern: the client asks, the server answers, and the exchange is over. If the server needs to tell the client something new, the client has no way to know unless it asks again, either by refreshing or by polling on a timer. A WebSocket connection, once opened, just stays open, and either side can write to it whenever something happens.
Why WebSockets over plain HTTP for a chat app
- Real-time delivery. There’s no polling delay. A message sent by one user reaches the others as soon as the server processes it.
- Server push. The server can send data without waiting for the client to ask. This is the core requirement of a chat app: your phone needs to receive a message that someone else sent, without you tapping refresh.
- Lower overhead per message. HTTP requests carry a full set of headers every time. Once a WebSocket connection is open, individual messages (“frames”) carry much less overhead, which matters when you’re sending many small messages quickly, as in chat, live scores, or multiplayer games.
- One persistent connection. The connection stays open until either side closes it, cutting the latency that comes from opening a new connection for every exchange.
WebSockets are not always the right choice. A long-lived connection consumes a server resource (an open socket and often a thread or coroutine) for every single connected client, for as long as they’re connected, whether or not they’re actively sending anything. For an app that mostly needs data occasionally, or where the client initiates almost everything, a plain HTTP request (or periodic polling) is simpler to build, easier to scale behind normal load balancers, and doesn’t need you to manage connection state at all. Reach for WebSockets when the defining feature is that the server needs to push data unprompted: chat, live notifications, collaborative editing, real-time dashboards, or multiplayer state.
The handshake: how HTTP becomes a WebSocket
A WebSocket connection doesn’t start life as a WebSocket. It starts as a normal HTTP request that asks to be upgraded, using HTTP’s built-in Upgrade mechanism.
- The client sends a standard HTTP request carrying two specific headers:
Upgrade: websocketandConnection: Upgrade, along with aSec-WebSocket-Key, a randomly generated, base64-encoded value. - If the server supports WebSockets, it replies not with a normal 200 OK but with HTTP 101 Switching Protocols, echoing its own
UpgradeandConnectionheaders, plus aSec-WebSocket-Acceptheader, which is a hash computed from the client’s key combined with a fixed, protocol-defined UID. The client checks this to confirm the server actually understood the WebSocket protocol, rather than, say, a misconfigured proxy just echoing headers back. - From this point on, the same underlying TCP connection is no longer speaking HTTP. Both sides now exchange WebSocket frames directly, and either one can send data at any time without waiting to be asked.
You never write this handshake by hand. Every WebSocket library, on both the client and server side, does it for you the moment you call something like connect(). It’s worth understanding conceptually, though, because it explains some things you’ll run into: why a WebSocket URL is written as ws:// or wss:// (the secure version, analogous to https://), and why certain proxies, load balancers, or corporate firewalls that don’t know how to handle the Upgrade header will silently break WebSocket connections while normal HTTP traffic through the same infrastructure works fine.
When WebSockets are the right tool (and when they aren’t)
| Good fit | Usually better served by plain HTTP |
|---|---|
| Chat and messaging | Loading a user’s profile or settings |
| Live notifications and presence (“user is typing”) | Submitting a form or uploading a file |
| Collaborative editing (multiple users editing the same document) | A product catalogue or search results page |
| Live dashboards, stock tickers, sports scores | Anything the client can just refresh or paginate through |
| Multiplayer game state | Occasional background sync (a periodic fetch is simpler) |
Three common backend approaches
You don’t need to hand-roll raw WebSocket frame handling yourself; the ecosystem has settled on a small number of well-supported approaches. Here’s how the three most common ones compare, since which one your backend uses changes how the Flutter client talks to it.
| Spring Boot + STOMP | Django Channels | Socket.IO | |
|---|---|---|---|
| Language / ecosystem | Java / Kotlin | Python (Django) | Node.js (or any language with a Socket.IO-compatible library) |
| Protocol on the wire | STOMP messages over WebSocket (with SockJS as a fallback transport) | Raw WebSocket, with Django’s own routing and “consumers” | Its own protocol on top of WebSocket, with automatic fallback to HTTP long-polling |
| Concept for grouping users | Topics and destinations (e.g. /topic/public) |
Channel “groups” (e.g. a room name shared by two users) | “Rooms” that a socket can join and leave |
| Good fit | Teams already on the Spring ecosystem; message-broker-style architecture | Teams already on Django; needs an ASGI server (Daphne or Uvicorn) instead of the usual WSGI server | Fastest to prototype; huge ecosystem; automatic reconnection and fallback transport built in |
| Flutter client library | stomp_dart_client, or connect over plain WebSocket if you implement the STOMP frame format yourself |
web_socket_channel (Django Channels speaks plain WebSocket once connected) |
socket_io_client |
A Spring Boot chat backend, for example, typically defines a WebSocket configuration that registers an endpoint (commonly /ws) and enables a simple message broker on a prefix like /topic, with the app itself listening for incoming messages on a prefix like /app. A controller method annotated to handle /app/chat.sendMessage can then broadcast to everyone subscribed to /topic/public just by returning a value, letting Spring’s message broker handle the fan-out to every connected client. A Django Channels backend does the equivalent with “consumers”: a Python class with connect, disconnect, and receive methods, plus a “room name” used to group two or more users into a channel layer group so a message sent by one is broadcast to the others in that same group.
Whichever backend you use, the Flutter side of the picture below stays largely the same for STOMP or Channels: you open a plain WebSocket connection and exchange JSON messages over it. Socket.IO is the one exception, since it uses its own protocol on top of WebSocket and needs a matching client library rather than plain web_socket_channel.
Setting up the Flutter side
For a backend that speaks plain WebSocket (Django Channels, or a Spring Boot endpoint you connect to directly without STOMP framing), Flutter’s official web_socket_channel package is the right tool: it’s maintained by the Dart team, works the same way on every platform, and doesn’t pull in any extra protocol logic you don’t need.
flutter pub add web_socket_channel
If your backend uses Socket.IO specifically, use socket_io_client instead, since it speaks Socket.IO’s own protocol (including its automatic reconnection and room semantics) rather than raw WebSocket frames. The rest of this guide uses web_socket_channel, since it’s the more general building block and what you’d reach for with either Spring Boot’s plain WebSocket endpoint or Django Channels.
Connecting to the WebSocket
Start with a small service class that owns the connection, rather than opening a WebSocket directly inside a widget. This keeps the connection alive across screen rebuilds and gives you one place to handle reconnection logic later.
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
class ChatSocketService {
WebSocketChannel? _channel;
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messages => _messageController.stream;
void connect(String roomName, String token) {
final uri = Uri.parse('wss://your-server.com/ws/chat/$roomName/?token=$token');
_channel = WebSocketChannel.connect(uri);
_channel!.stream.listen(
(raw) {
final data = jsonDecode(raw as String) as Map<String, dynamic>;
_messageController.add(data);
},
onError: (error) {
// handled in the reconnect logic below
},
onDone: () {
// the server or network closed the connection
},
);
}
void send(Map<String, dynamic> payload) {
_channel?.sink.add(jsonEncode(payload));
}
void dispose() {
_channel?.sink.close();
_messageController.close();
}
}
A few choices here are worth calling out:
- The room name goes in the URL, the auth token as a query parameter. WebSocket connection requests can’t carry a custom
Authorizationheader the way a normal HTTP request can from a browser fetch (the browser WebSocket API doesn’t expose custom headers), so passing a token as a query string parameter, validated by the server, is the common workaround. If your backend framework supports it, an alternative is sending the token as the very first message after connecting and having the server authenticate before allowing anything else through. - Use
wss://, notws://, for anything real. Just like HTTP vs. HTTPS, the unencryptedws://scheme sends everything, including your auth token and every chat message, in plain text over the network. - A broadcast
StreamControllerlets more than one widget listen to incoming messages (for example, a badge counter in the app bar and the chat screen itself) without fighting over who “owns” the single-subscription stream thatchannel.streamgives you directly.
Sending and receiving messages
Define a small model for a chat message rather than passing raw maps around your UI code:
enum MessageType { chat, join, leave }
class ChatMessage {
final String sender;
final String content;
final MessageType type;
final DateTime sentAt;
ChatMessage({
required this.sender,
required this.content,
required this.type,
required this.sentAt,
});
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
sender: json['sender'] as String,
content: json['content'] as String? ?? '',
type: MessageType.values.firstWhere(
(t) => t.name == json['type'],
orElse: () => MessageType.chat,
),
sentAt: DateTime.now(),
);
}
Map<String, dynamic> toJson() => {
'sender': sender,
'content': content,
'type': type.name,
};
}
Sending a message becomes a matter of building one of these and handing it to the socket service:
void sendChatMessage(String text, String username) {
final message = ChatMessage(
sender: username,
content: text,
type: MessageType.chat,
sentAt: DateTime.now(),
);
chatSocketService.send(message.toJson());
}
On the receiving side, listen to the service’s stream and turn each incoming map into a ChatMessage, appending it to whatever list your UI is rendering (a ListView, or state managed through Provider, Riverpod, or Bloc, depending on what the rest of your app uses):
chatSocketService.messages.listen((data) {
final message = ChatMessage.fromJson(data);
setState(() {
_messages.add(message);
});
});
Handling disconnects and reconnecting
This is the part almost every basic tutorial skips, and it’s the difference between a demo and something you’d actually ship. Mobile networks are unreliable: users walk into elevators, switch from Wi-Fi to cellular, or lock their phone and the OS suspends the app. A WebSocket connection will drop, and your app needs to notice and recover, rather than silently going deaf.
class ChatSocketService {
// ...fields from before...
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
bool _manuallyDisconnected = false;
void connect(String roomName, String token) {
_manuallyDisconnected = false;
final uri = Uri.parse('wss://your-server.com/ws/chat/$roomName/?token=$token');
_channel = WebSocketChannel.connect(uri);
_channel!.stream.listen(
(raw) {
_reconnectAttempts = 0; // reset backoff once we hear from the server
final data = jsonDecode(raw as String) as Map<String, dynamic>;
_messageController.add(data);
},
onError: (_) => _scheduleReconnect(roomName, token),
onDone: () => _scheduleReconnect(roomName, token),
);
}
void _scheduleReconnect(String roomName, String token) {
if (_manuallyDisconnected) return;
_reconnectAttempts++;
final delaySeconds = (2 * _reconnectAttempts).clamp(2, 30);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(Duration(seconds: delaySeconds), () {
connect(roomName, token);
});
}
void disconnect() {
_manuallyDisconnected = true;
_reconnectTimer?.cancel();
_channel?.sink.close();
}
}
The key ideas here:
- Exponential-ish backoff, capped at a sane maximum. Retrying instantly in a tight loop when the server is genuinely down just hammers it further. Waiting a little longer after each failed attempt, up to a ceiling (30 seconds here), is kinder to your backend and your user’s battery.
- A “manually disconnected” flag. Without it, calling
disconnect()when a user leaves the chat screen will still triggeronDone, which would otherwise schedule a pointless reconnect attempt for a connection you closed on purpose. - Handle messages sent while offline separately. If a user types a message with no connection, don’t just drop it. A common pattern (visible in production chat apps) is to save it locally with a temporary ID and a “sending” state, attempt to send once reconnected, and reconcile the temporary message with the server’s confirmed version once it comes back, rather than showing the user a message that silently vanished.
Building the chat screen
With the socket service and message model in place, the actual screen is fairly ordinary Flutter: a scrollable list of messages and an input row.
class ChatScreen extends StatefulWidget {
final String roomName;
final String username;
const ChatScreen({super.key, required this.roomName, required this.username});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final _socketService = ChatSocketService();
final _controller = TextEditingController();
final _scrollController = ScrollController();
final List<ChatMessage> _messages = [];
@override
void initState() {
super.initState();
_socketService.connect(widget.roomName, 'YOUR_AUTH_TOKEN');
_socketService.messages.listen((data) {
setState(() => _messages.add(ChatMessage.fromJson(data)));
_scrollToBottom();
});
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
void _send() {
final text = _controller.text.trim();
if (text.isEmpty) return;
_socketService.sendChatMessage(text, widget.username);
_controller.clear();
}
@override
void dispose() {
_socketService.disconnect();
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.roomName)),
body: Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
final isMe = message.sender == widget.username;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isMe ? Colors.blue[100] : Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: Text('${message.sender}: ${message.content}'),
),
);
},
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(hintText: 'Type a message'),
),
),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
),
),
],
),
);
}
}
Two small but easy-to-miss details in this screen: _scrollToBottom runs inside addPostFrameCallback so it executes after the list has actually rebuilt with the new message, and every message bubble is aligned based on whether the sender matches the current user, which is what makes the layout look like a normal chat interface rather than a plain log of text.
Making it production-ready
The pieces above get you a working chat screen. A few things separate that from something you’d actually ship to users:
- Persist messages locally. Store received and sent messages in a local database (such as Drift, Isar, or Hive) so a user reopening the app sees their conversation history immediately, before the socket even reconnects, rather than a blank screen.
- Deduplicate on reconnect. When you reconnect after a drop, you may fetch recent history from a REST endpoint as a catch-up mechanism alongside the live socket. Give every message a stable ID from the server, and check for that ID before appending, so the same message doesn’t show up twice.
- Separate connection state from message state. A dedicated “connecting / connected / disconnected” indicator in the UI (even a small dot near the app bar title) tells users why messages might be delayed, instead of leaving them guessing whether the app is broken.
- Close the connection when the app is backgrounded, if it makes sense for your product. Keeping sockets open indefinitely in the background affects battery life and, on iOS in particular, the OS will suspend background networking anyway after a short period. Many chat apps disconnect on backgrounding and reconnect (fetching anything missed via REST) when the app returns to the foreground, using a push notification service to alert users to new messages in the meantime.
- Authenticate the connection itself, not just the app. Don’t rely on the WebSocket URL being hard to guess. Validate the token server-side before accepting the connection or allowing it to join a room, exactly as you would with a REST endpoint.
- Load-test before you scale up. A server that handles ten concurrent WebSocket connections comfortably may behave very differently at ten thousand, since each open connection ties up server resources for its entire lifetime. This is one of the reasons frameworks like Django need an ASGI server (Daphne or Uvicorn) rather than the traditional WSGI server, and why Spring Boot’s WebSocket support is built around a dedicated message broker rather than handling every connection ad hoc.
Frequently asked questions
Do I need a special Flutter package for WebSockets?
For a plain WebSocket backend (Django Channels, or a raw WebSocket endpoint), the official web_socket_channel package is enough. If your backend specifically uses Socket.IO, use socket_io_client instead, since Socket.IO layers its own protocol and fallback behaviour on top of WebSocket.
What’s the difference between WebSocket and Socket.IO?
WebSocket is the underlying browser and network protocol (RFC 6455). Socket.IO is a library built on top of it that adds its own framing, automatic reconnection, “rooms” for grouping clients, and a fallback to HTTP long-polling when a WebSocket connection can’t be established. A Socket.IO server needs a Socket.IO-aware client; a plain WebSocket client can’t talk to it directly.
Why does my WebSocket connection work locally but fail in production?
The most common causes are a reverse proxy or load balancer that isn’t configured to forward the Upgrade and Connection headers, or using ws:// where the deployment requires wss:// (some networks and app store review processes expect encrypted connections). Check your proxy’s WebSocket-specific configuration first.
How do I keep messages in order if the network is unreliable?
Give every message a sequence number or timestamp from the server, not just the client, and sort or reconcile by that value rather than trusting arrival order, since a reconnect or a retry can deliver messages out of the order they were sent.
Should I use Firebase instead of building this myself?
Firebase’s Realtime Database or Firestore can also power a chat feature, using their own real-time listener APIs instead of raw WebSockets, and can be a faster starting point if you don’t need a custom backend. Building your own WebSocket backend makes more sense when you need custom message routing, an existing backend (Spring Boot or Django) you’re already running, or want full control over scaling and data storage.
How do group chats change this setup?
The core mechanism is the same; only the “room” concept changes. Instead of a room shared by exactly two users, the server groups every participant into the same channel or topic, and a message from any one of them is broadcast to all the others in that group. The Flutter client code above doesn’t need to change at all, since it already just connects to a room and listens for messages, whether that room has two people or twenty.
Conclusion
A WebSocket connection is a straightforward idea, an HTTP handshake that upgrades to a persistent, two-way channel, but a solid chat feature is built from several smaller pieces around that core: a stable connection service, a clear message model, honest handling of the moments when the network fails, and a UI that reflects connection state instead of hiding it. Whether your backend speaks STOMP, Django Channels, or Socket.IO, the Flutter client’s job stays the same: open the socket, send and receive JSON, and recover gracefully when the connection drops.
Start with the plain web_socket_channel setup above against whichever backend you already have, get a single room working end to end, and layer in persistence, reconnection, and group chat once that foundation is solid.