Web Socket #
WebSocket is a full-duplex communication protocol over TCP that starts with an HTTP handshake and then upgrades to a persistent connection. Unlike regular HTTP, which is request-response, WebSocket lets the server send data to the client at any time without waiting for a request. This makes it the ideal choice for real-time features: chat, live notifications, auto-updating dashboards, and multiplayer games. Dart supports WebSocket through dart:io — this article covers building reliable servers and clients, complete with heartbeats, authentication, and automatic reconnection.
WebSocket vs Real-Time Alternatives #
Before building a WebSocket, make sure it’s truly the right choice for your needs:
flowchart TD
A{Real-time needs?} --> B{Does the server need to send\\ndata to the client\\nwithout a request trigger?}
B -- No --> C[Regular HTTP + polling\\nis enough]
B -- Yes --> D{Two-way\\ncommunication?}
D -- Not often\\nServer → Client only --> E[Server-Sent Events\\nSSE is simpler]
D -- Yes, frequent\\nback and forth --> F{Need very low\\nlatency?}
F -- Yes --> G[WebSocket\\ndart:io]
F -- No --> H[WebSocket or\\nSSE + HTTP POST]| Method | Direction | Latency | Overhead | Best for |
|---|---|---|---|---|
| HTTP Polling | Client → Server | High | High | Infrequent updates |
| SSE | Server → Client | Low | Low | Notifications, feeds |
| WebSocket | Two-way | Very low | Moderate | Chat, games, collaboration |
How WebSocket Works #
WebSocket starts as a regular HTTP request, then is upgraded to the WebSocket protocol:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: HTTP GET /ws\nUpgrade: websocket\nConnection: Upgrade\nSec-WebSocket-Key: ...
S-->>C: HTTP 101 Switching Protocols\nUpgrade: websocket\nSec-WebSocket-Accept: ...
Note over C,S: WebSocket connection active — full-duplex
C->>S: Frame: "Hello server"
S-->>C: Frame: "Hello client"
S-->>C: Frame: "Data update"
C->>S: Frame: Ping
S-->>C: Frame: Pong
C->>S: Frame: Close (1000, "Normal closure")
S-->>C: Frame: Close (1000, "OK")Basic WebSocket Server #
import 'dart:io';
import 'dart:convert';
Future<void> main() async {
// HttpServer handles the HTTP handshake and upgrade to WebSocket
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
print('WebSocket server: ws://localhost:${server.port}/ws');
await for (final request in server) {
if (request.uri.path == '/ws' &&
WebSocketTransformer.isUpgradeRequest(request)) {
// Upgrade the HTTP connection to WebSocket
final socket = await WebSocketTransformer.upgrade(request);
_tanganiKlien(socket, request);
} else {
// Reject requests that aren't WebSocket upgrades
request.response
..statusCode = HttpStatus.badRequest
..write('Only WebSocket connections are accepted')
..close();
}
}
}
void _tanganiKlien(WebSocket socket, HttpRequest request) {
final alamat = request.connectionInfo?.remoteAddress.address ?? 'unknown';
print('Client connected: $alamat');
socket.listen(
(pesan) {
print('Received from $alamat: $pesan');
// Send back (echo)
socket.add('Echo: $pesan');
},
onError: (error) {
print('Error from $alamat: $error');
},
onDone: () {
print('Client $alamat disconnected (close code: ${socket.closeCode})');
},
cancelOnError: true,
);
}
Message Envelope — Structured Message Format #
For a server handling various message types, use a JSON envelope format so the server can know the message type and route it to the right handler:
import 'dart:convert';
import 'dart:io';
// Message type definitions
enum TipePesan { chat, notifikasi, status, error, ping, pong }
// Standard envelope for all messages
class Pesan {
final TipePesan tipe;
final Map<String, dynamic> data;
final String? dari;
final DateTime waktu;
Pesan({
required this.tipe,
required this.data,
this.dari,
DateTime? waktu,
}) : waktu = waktu ?? DateTime.now().toUtc();
factory Pesan.dariJson(String json) {
final map = jsonDecode(json) as Map<String, dynamic>;
return Pesan(
tipe: TipePesan.values.byName(map['tipe'] as String),
data: map['data'] as Map<String, dynamic>,
dari: map['dari'] as String?,
waktu: DateTime.parse(map['waktu'] as String),
);
}
String keJson() => jsonEncode({
'tipe': tipe.name,
'data': data,
'dari': dari,
'waktu': waktu.toIso8601String(),
});
}
// Handler by message type
void prosesPesan(WebSocket socket, String rawPesan, String idKlien) {
try {
final pesan = Pesan.dariJson(rawPesan);
switch (pesan.tipe) {
case TipePesan.chat:
_prosesChat(socket, pesan, idKlien);
case TipePesan.ping:
_balasPong(socket);
case TipePesan.status:
_updateStatus(socket, pesan, idKlien);
default:
socket.add(Pesan(
tipe: TipePesan.error,
data: {'pesan': 'Unknown message type: ${pesan.tipe.name}'},
).keJson());
}
} on FormatException {
socket.add(Pesan(
tipe: TipePesan.error,
data: {'pesan': 'Invalid JSON format'},
).keJson());
}
}
void _balasPong(WebSocket socket) {
socket.add(Pesan(
tipe: TipePesan.pong,
data: {'waktuServer': DateTime.now().toUtc().toIso8601String()},
).keJson());
}
Chat Server with Rooms #
The rooms/channels pattern lets messages be sent only to the subset of clients that joined a particular room:
import 'dart:io';
import 'dart:convert';
class ServerChat {
// Map from roomId to the Set of joined WebSockets
final Map<String, Set<WebSocket>> _rooms = {};
// Map from WebSocket to client info
final Map<WebSocket, Map<String, String>> _infoKlien = {};
void _bergabungRoom(WebSocket socket, String roomId, String namaUser) {
_rooms.putIfAbsent(roomId, () => {}).add(socket);
_infoKlien[socket] = {'room': roomId, 'nama': namaUser};
// Notify everyone in the room that someone joined
_broadcastKeRoom(roomId, jsonEncode({
'tipe': 'sistem',
'pesan': '$namaUser joined room $roomId',
'jumlahOnline': _rooms[roomId]!.length,
}), kecuali: socket);
print('$namaUser joined room $roomId');
}
void _keluar(WebSocket socket) {
final info = _infoKlien.remove(socket);
if (info == null) return;
final roomId = info['room']!;
final nama = info['nama']!;
_rooms[roomId]?.remove(socket);
if (_rooms[roomId]?.isEmpty ?? false) {
_rooms.remove(roomId); // clean up empty rooms
}
_broadcastKeRoom(roomId, jsonEncode({
'tipe': 'sistem',
'pesan': '$nama left the room',
'jumlahOnline': _rooms[roomId]?.length ?? 0,
}));
}
void _broadcastKeRoom(String roomId, String pesan, {WebSocket? kecuali}) {
final anggota = _rooms[roomId] ?? {};
for (final socket in anggota) {
if (socket != kecuali && socket.readyState == WebSocket.open) {
try {
socket.add(pesan);
} catch (e) {
// The client may already be disconnected
}
}
}
}
void tanganiKlien(WebSocket socket, HttpRequest request) {
socket.listen(
(pesan) {
try {
final data = jsonDecode(pesan as String) as Map<String, dynamic>;
final tipe = data['tipe'] as String;
switch (tipe) {
case 'bergabung':
_bergabungRoom(
socket,
data['room'] as String,
data['nama'] as String,
);
case 'chat':
final info = _infoKlien[socket];
if (info != null) {
_broadcastKeRoom(info['room']!, jsonEncode({
'tipe': 'chat',
'dari': info['nama'],
'pesan': data['pesan'],
'waktu': DateTime.now().toUtc().toIso8601String(),
}));
}
case 'daftarRoom':
socket.add(jsonEncode({
'tipe': 'daftarRoom',
'rooms': _rooms.map((k, v) => MapEntry(k, v.length)),
}));
}
} on FormatException {
socket.add(jsonEncode({'tipe': 'error', 'pesan': 'Invalid JSON'}));
}
},
onDone: () => _keluar(socket),
onError: (_) => _keluar(socket),
cancelOnError: true,
);
}
}
Future<void> main() async {
final server = ServerChat();
final httpServer = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in httpServer) {
if (WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
server.tanganiKlien(socket, request);
}
}
}
Heartbeat — Keeping Connections Alive #
Idle WebSocket connections can be dropped by proxies, load balancers, or firewalls after a few minutes. Implement a ping-pong heartbeat to detect dead connections and keep connections active:
import 'dart:async';
import 'dart:io';
class KoneksiWebSocket {
final WebSocket _socket;
Timer? _pingTimer;
Timer? _pongTimeout;
bool _menunggoPong = false;
static const _intervalPing = Duration(seconds: 30);
static const _timeoutPong = Duration(seconds: 10);
KoneksiWebSocket(this._socket) {
_mulaiHeartbeat();
}
void _mulaiHeartbeat() {
_pingTimer = Timer.periodic(_intervalPing, (_) => _kirimPing());
}
void _kirimPing() {
if (_socket.readyState != WebSocket.open) {
_hentikanHeartbeat();
return;
}
_menunggoPong = true;
_socket.add('__ping__'); // send a ping
// If there's no pong within 10 seconds, consider the connection dead
_pongTimeout = Timer(_timeoutPong, () {
if (_menunggoPong) {
print('Client didn't respond to ping — closing the connection');
_socket.close(WebSocketStatus.goingAway, 'Ping timeout');
}
});
}
void prosesData(dynamic pesan) {
if (pesan == '__pong__') {
_menunggoPong = false;
_pongTimeout?.cancel();
return;
}
if (pesan == '__ping__') {
_socket.add('__pong__');
return;
}
// Process regular business messages
_prosesPesan(pesan);
}
void _hentikanHeartbeat() {
_pingTimer?.cancel();
_pongTimeout?.cancel();
}
void tutup([int code = WebSocketStatus.normalClosure, String reason = '']) {
_hentikanHeartbeat();
_socket.close(code, reason);
}
void _prosesPesan(dynamic pesan) {
// message handler implementation
}
}
WebSocket Client with Automatic Reconnection #
A reliable client must be able to reconnect when the connection drops:
import 'dart:io';
import 'dart:async';
import 'dart:convert';
class KlienWebSocket {
final String url;
WebSocket? _socket;
bool _harusTerhubung = true;
int _percobaan = 0;
final _controllerPesan = StreamController<dynamic>.broadcast();
Stream<dynamic> get pesan => _controllerPesan.stream;
KlienWebSocket(this.url);
Future<void> hubungkan() async {
_harusTerhubung = true;
await _cobaTerhubung();
}
Future<void> _cobaTerhubung() async {
while (_harusTerhubung) {
try {
_percobaan++;
print('Connecting to $url (attempt $_percobaan)...');
_socket = await WebSocket.connect(url).timeout(
const Duration(seconds: 10),
);
_percobaan = 0; // reset the counter after success
print('Connected to $url');
// Listen for messages
await for (final pesan in _socket!) {
_controllerPesan.add(pesan);
}
// Socket closed (onDone)
if (_harusTerhubung) {
print('Connection lost — trying to reconnect...');
}
} on SocketException catch (e) {
print('Failed to connect: $e');
} on TimeoutException {
print('Timeout while connecting');
}
if (!_harusTerhubung) break;
// Exponential backoff: 1, 2, 4, 8, 16 seconds (maximum)
final tunda = Duration(seconds: (1 << _percobaan.clamp(0, 4)));
print('Reconnecting in ${tunda.inSeconds}s...');
await Future.delayed(tunda);
}
}
void kirim(String pesan) {
if (_socket?.readyState == WebSocket.open) {
_socket!.add(pesan);
} else {
throw StateError('WebSocket not connected');
}
}
void kirimJson(Map<String, dynamic> data) {
kirim(jsonEncode(data));
}
Future<void> putuskan() async {
_harusTerhubung = false;
await _socket?.close(WebSocketStatus.normalClosure, 'Client disconnect');
await _controllerPesan.close();
}
}
// Usage
Future<void> main() async {
final klien = KlienWebSocket('ws://localhost:8080/ws');
klien.pesan.listen((pesan) {
print('Message from the server: $pesan');
});
await klien.hubungkan();
klien.kirimJson({'tipe': 'bergabung', 'room': 'umum', 'nama': 'Budi'});
klien.kirimJson({'tipe': 'chat', 'pesan': 'Hello everyone!'});
}
WebSocket Authentication #
WebSocket has no built-in authentication mechanism. Two common approaches:
Via Query Parameter (simple) #
// Client: ws://localhost:8080/ws?token=eyJhbGciOi...
Future<void> main() async {
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in server) {
if (!WebSocketTransformer.isUpgradeRequest(request)) continue;
// Get the token from the query string
final token = request.uri.queryParameters['token'];
if (token == null || !await verifikasiToken(token)) {
request.response
..statusCode = HttpStatus.unauthorized
..write('Invalid token')
..close();
continue;
}
final idPengguna = ambilIdDariToken(token);
final socket = await WebSocketTransformer.upgrade(request);
_tanganiKlienTerautentikasi(socket, idPengguna);
}
}
Via the Authorization Header (more secure)
#
// The client sends: Authorization: Bearer ***
await for (final request in server) {
if (!WebSocketTransformer.isUpgradeRequest(request)) continue;
final auth = request.headers.value(HttpHeaders.authorizationHeader);
if (auth == null || !auth.startsWith('Bearer ')) {
request.response.statusCode = HttpStatus.unauthorized;
await request.response.close();
continue;
}
final token = auth.substring(7); // remove "Bearer "
if (!await verifikasiToken(token)) {
request.response.statusCode = HttpStatus.forbidden;
await request.response.close();
continue;
}
final socket = await WebSocketTransformer.upgrade(request);
// Continue...
}
Secure WebSocket (WSS) #
For production, always use wss:// (WebSocket over TLS):
import 'dart:io';
// WSS server
Future<void> serverWSS() async {
final konteks = SecurityContext()
..useCertificateChain('server.crt')
..usePrivateKey('server.key');
// HttpServer.bindSecure = HTTPS, upgrade to WSS
final server = await HttpServer.bindSecure(
InternetAddress.anyIPv4,
443,
konteks,
);
print('WSS server: wss://localhost:443/ws');
await for (final request in server) {
if (WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
// handle as usual
}
}
}
// WSS client
Future<void> clientWSS() async {
// wss:// automatically uses TLS
final socket = await WebSocket.connect('wss://api.example.com/ws');
socket.listen((data) => print('Data: $data'));
}
Close Codes — Closing with Meaning #
WebSocket defines standard closing codes that communicate the disconnect reason:
// Commonly used WebStatus codes
class WebSocketStatus {
static const int normalClosure = 1000; // normal closure
static const int goingAway = 1001; // server restart / client navigation
static const int protocolError = 1002; // protocol violation
static const int unsupportedData = 1003; // unsupported data type
static const int internalServerError = 1011; // unexpected server error
}
// Closing with a meaningful code and reason
void tutupDenganAlasan(WebSocket socket, String alasan) {
socket.close(WebSocketStatus.normalClosure, alasan);
}
// On the server: handle the client's close code
socket.listen(
(_) {},
onDone: () {
final code = socket.closeCode;
final reason = socket.closeReason;
print('Closed with code $code: $reason');
if (code == WebSocketStatus.goingAway) {
// The browser client navigated to another page — normal
} else if (code == WebSocketStatus.internalServerError) {
// There's an error on the client — log for investigation
_log.error('Client error: $reason');
}
},
);
WebSocket Anti-Patterns #
Global List Without Cleanup #
// ANTI-PATTERN: a global list that's never cleaned up
final List<WebSocket> semua = [];
void tambah(WebSocket ws) => semua.add(ws);
void broadcast(String pesan) {
for (final ws in semua) {
ws.add(pesan); // ✗ can throw if ws is already closed
}
}
// ✗ disconnected websockets are never removed — memory leak!
// CORRECT: remove on done, check readyState before sending
void broadcast(String pesan) {
semua.removeWhere((ws) => ws.readyState != WebSocket.open);
for (final ws in semua) {
try {
ws.add(pesan);
} catch (_) { /* ignored */ }
}
}
Not Handling Errors When Sending #
// ANTI-PATTERN: sending without error handling
void kirimKeSemuaKlien(String pesan) {
for (final ws in klien) {
ws.add(pesan); // ✗ throws StateError if ws is already closed
}
}
// CORRECT: check readyState and handle exceptions
void kirimKeSemuaKlien(String pesan) {
for (final ws in klien.toList()) { // .toList() for safety during modification
if (ws.readyState == WebSocket.open) {
try {
ws.add(pesan);
} catch (e) {
print('Failed to send to client: $e');
klien.remove(ws);
}
} else {
klien.remove(ws);
}
}
}
Summary #
- WebSocket is an upgrade from HTTP — it starts with an HTTP GET containing the
Upgrade: websocketheader, then the server responds with101 Switching Protocols.WebSocketTransformer.upgrade(request)handles this automatically.- Use a JSON message envelope — wrap all messages in a
{tipe, data, waktu}structure so the server can route to the right handler based on the message type.- Rooms/channels for multi-room apps — store clients in a
Map<String, Set<WebSocket>>, broadcast only to members of the same room.- A ping-pong heartbeat is mandatory for long-lived connections — send a ping every 30 seconds, close the connection if there’s no pong within 10 seconds. Without it, dead connections may go undetected for minutes.
- Reconnect with exponential backoff on the client — wait 1, 2, 4, 8 seconds before retrying, not immediate repeated retries.
- Authenticate before the upgrade — verify the token in the
Authorizationheader or query parameter before callingWebSocketTransformer.upgrade. After the upgrade, there’s no way to send an HTTP 401.- Check
readyState == WebSocket.openbefore sending — sending to an already closed socket throws aStateError.- WSS (
wss://) for production — useHttpServer.bindSecureon the server andwss://in the client URL. Plainws://is only for local development.- Handle close codes —
onDoneprovidessocket.closeCodeandsocket.closeReasonto distinguish a normal disconnect (1000) from an error (1011).- Clean up resources on
onDone— remove the WebSocket from all collections, cancel timers, and close related stream controllers.