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]
MethodDirectionLatencyOverheadBest for
HTTP PollingClient → ServerHighHighInfrequent updates
SSEServer → ClientLowLowNotifications, feeds
WebSocketTwo-wayVery lowModerateChat, 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: websocket header, then the server responds with 101 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 Authorization header or query parameter before calling WebSocketTransformer.upgrade. After the upgrade, there’s no way to send an HTTP 401.
  • Check readyState == WebSocket.open before sending — sending to an already closed socket throws a StateError.
  • WSS (wss://) for production — use HttpServer.bindSecure on the server and wss:// in the client URL. Plain ws:// is only for local development.
  • Handle close codesonDone provides socket.closeCode and socket.closeReason to 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.

← Previous: Socket   Next: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact