Dart 76 🎯 WebSockets Implementation in Pure Dart Client and Server Applications
A WebSocket is a persistent, bidirectional connection between a client and a server, established through an HTTP upgrade handshake and then maintained as a raw TCP socket. Unlike HTTP, where the client must ask for every response, a WebSocket lets either side send a message at any time. Dart’s standard library provides first-class support for both sides of this connection: dart:io includes a WebSocket class that can act as a client or a server, a WebSocketTransformer that upgrades incoming HTTP requests, and a HttpServer that hosts the upgrade endpoint . No third-party package is required for a pure Dart implementation.
The distinction between the client and server sides is mostly about who initiates the handshake. A client calls WebSocket.connect(url), which performs the HTTP upgrade and returns a WebSocket once the connection is established. A server binds an HttpServer, listens for requests, checks whether each request is a WebSocket upgrade, and calls WebSocketTransformer.upgrade(request) to convert it into a WebSocket . After the upgrade, both sides use the same interface: add() to send, a Stream subscription to receive, and close() to terminate. The protocol details—framing, masking, ping/pong—are handled by the library.
Key point: On the server, use HttpServer.bind() and WebSocketTransformer.upgrade() to accept connections; on the client, use WebSocket.connect() with a ws:// or wss:// URL. Both return a WebSocket that sends via add() and receives via its Stream. The pingInterval property is the mechanism that detects dead connections by requiring a pong response within the interval .
Why pure Dart WebSockets matter
The dependency problem. Many WebSocket tutorials start with package:web_socket_channel, which is a cross-platform wrapper that works in Flutter and on the web . That package is valuable when the code must run in a browser, because dart:io is not available there. But a server-side Dart application, a command-line client, or a test running on the Dart VM does not need the wrapper. The dart:io implementation is the underlying engine, and using it directly removes a dependency and exposes the full API—including the pingInterval, the readyState, and the close codes—without the wrapper’s abstraction .
The upgrade problem. A WebSocket connection begins as an HTTP request with an Upgrade: websocket header. The server must recognize that request, validate the handshake, and switch protocols. WebSocketTransformer.isUpgradeRequest(request) performs the check, and WebSocketTransformer.upgrade(request) performs the switch . The transformer handles the Sec-WebSocket-Accept key computation and the protocol negotiation. Writing this by hand is possible but error-prone; the standard library provides it so the application can focus on the messages.
The dead-connection problem. A TCP connection can be silently dropped—a laptop closes, a network cable is pulled, a mobile device loses signal—without the socket emitting a close event. The pingInterval property solves this. When it is set, the WebSocket sends a ping frame at the interval and expects a pong frame within the same interval. If the pong does not arrive, the connection is closed with the GOING_AWAY status code . This is the mechanism that keeps a server from accumulating connections to clients that no longer exist.
The message-type problem. The WebSocket stream exposes text messages as String and binary messages as List<int> . The application does not need to parse a frame header to know which type arrived; the Dart type of the stream event tells it. The add() method accepts either a String or a List<int>, and the library chooses the correct frame type based on the Dart type of the argument.
a. The server: binding and upgrading
The server side has three steps: bind an HttpServer, listen for requests, and upgrade the ones that are WebSocket handshakes.
// Import dart:io for HttpServer, WebSocket, and WebSocketTransformer
import 'dart:io';
void main() async {
// Bind an HTTP server to localhost on port 8080
// The server accepts plain HTTP requests, including WebSocket upgrade requests
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 8080);
print('Server listening on ws://${server.address.address}:${server.port}');
// Listen for every incoming HTTP request
await for (final request in server) {
// Check whether the request is a WebSocket upgrade
// A normal GET request for a page is not, and is rejected here
if (WebSocketTransformer.isUpgradeRequest(request)) {
// Perform the handshake and convert the request to a WebSocket
final socket = await WebSocketTransformer.upgrade(request);
// Handle the connected socket
handleConnection(socket);
} else {
// Reject non-WebSocket requests with a 400
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
}
}
}
void handleConnection(WebSocket socket) {
// Print the remote address for logging
print('Client connected: ${socket.remoteAddress.address}');
// Listen for messages from this client
socket.listen(
(message) {
// The message is a String for text frames or List<int> for binary frames
print('Received: $message');
// Echo the message back to the client
// The add method accepts String or List<int>
socket.add('Echo: $message');
},
// Handle a clean close from the client
onDone: () => print('Client disconnected'),
// Handle an error on the socket
onError: (error) => print('Socket error: $error'),
// Do not cancel the subscription on error; close the socket instead
cancelOnError: false,
);
}
The WebSocketTransformer.isUpgradeRequest check is the gate. A request that is not an upgrade is rejected with a 400, which is the correct HTTP status for a malformed request. The upgrade call returns a Future<WebSocket> that completes after the handshake, and the handleConnection function is where the application logic lives. The socket.listen call is the receive loop; the onDone and onError callbacks are the lifecycle hooks.
The pingInterval should be set on the server side to detect dead clients:
// After the upgrade, set a ping interval
// The WebSocket sends a ping every 30 seconds and expects a pong
// If the pong does not arrive, the connection is closed with GOING_AWAY
socket.pingInterval = const Duration(seconds: 30);
Without the ping interval, a client that disappears without closing the TCP connection—a mobile device that loses signal—remains in the server’s connection list indefinitely. The ping/pong exchange is the only reliable way to detect the loss .
b. The client: connecting and listening
The client side is simpler. WebSocket.connect performs the handshake and returns a Future<WebSocket> that completes when the connection is ready.
// Import dart:io for WebSocket
import 'dart:io';
void main() async {
// Connect to the server
// The URL scheme must be ws (plain) or wss (TLS)
final socket = await WebSocket.connect('ws://127.0.0.1:8080');
// Print the negotiated protocol, if any
// The server may select a subprotocol from the ones the client offers
print('Connected. Protocol: ${socket.protocol}');
// Listen for messages from the server
socket.listen(
(message) {
// The message is a String for text or List<int> for binary
print('Server says: $message');
},
onDone: () => print('Connection closed by server'),
onError: (error) => print('Connection error: $error'),
);
// Send a text message to the server
socket.add('Hello from the client');
// Close the connection after a delay to allow the echo to arrive
await Future.delayed(const Duration(seconds: 2));
await socket.close();
}
The WebSocket.connect function accepts optional protocols and headers arguments. The protocols argument is a list of subprotocol names the client is willing to speak; the server selects one and returns it in the protocol property . The headers argument adds HTTP headers to the handshake request, which is how authentication tokens or cookies are passed when the server requires them. The connection, sec-websocket-key, sec-websocket-protocol, sec-websocket-version, and upgrade headers are controlled by the library and are ignored if the application tries to set them .
The close sequence is the other lifecycle detail. socket.close(code, reason) sends a close frame with the given status code and reason, then waits for the peer’s close frame. The await ensures the close handshake completes before the process exits. If the server initiates the close, the onDone callback fires, and the closeCode and closeReason properties contain the server’s values .
c. Structured messages and error handling
A WebSocket carries text or binary data. The application decides the format. The simplest format is plain text, but a real application usually needs structure—a message type, a payload, maybe a correlation ID. The format is the application’s choice; the WebSocket does not impose one.
// Import dart:convert for jsonEncode and jsonDecode
import 'dart:convert';
import 'dart:io';
// A simple structured message format
// The type field distinguishes message kinds
// The payload carries the data
// The id allows the client to correlate a response with a request
class Message {
final String type;
final Map<String, dynamic> payload;
final String? id;
Message({required this.type, required this.payload, this.id});
// Encode to a JSON string for the WebSocket frame
String encode() => jsonEncode({
'type': type,
'payload': payload,
if (id != null) 'id': id,
});
// Decode a JSON string into a Message
// Throws a FormatException if the string is not valid JSON
// or does not have the expected shape
static Message decode(String raw) {
final map = jsonDecode(raw) as Map<String, dynamic>;
return Message(
type: map['type'] as String,
payload: map['payload'] as Map<String, dynamic>,
id: map['id'] as String?,
);
}
}
void handleConnection(WebSocket socket) {
socket.listen(
(raw) {
try {
// Decode the incoming text into the structured message
final message = Message.decode(raw as String);
// Route based on the message type
switch (message.type) {
case 'ping':
// Respond with a pong carrying the same ID
socket.add(Message(type: 'pong', payload: {}, id: message.id).encode());
case 'echo':
// Echo the payload back
socket.add(Message(type: 'echo', payload: message.payload).encode());
default:
// Unknown type: send an error message
socket.add(Message(
type: 'error',
payload: {'reason': 'Unknown type: ${message.type}'},
id: message.id,
).encode());
}
} on FormatException catch (e) {
// The message was not valid JSON or not the expected shape
socket.add(Message(
type: 'error',
payload: {'reason': 'Invalid message format: $e'},
).encode());
} on TypeError catch (e) {
// The JSON was valid but a field had the wrong type
socket.add(Message(
type: 'error',
payload: {'reason': 'Invalid field type: $e'},
).encode());
}
},
onError: (error) => print('Socket error: $error'),
onDone: () => print('Connection closed'),
);
}
The try/catch around the decode is the error boundary. A malicious or buggy client can send a string that is not JSON, or a JSON object that does not match the expected shape. Without the catch, the exception would propagate to the onError handler, and the connection would likely be closed. With the catch, the server responds with a structured error message and keeps the connection open, which is the correct behavior for a protocol that expects the client to retry.
The jsonEncode and jsonDecode functions are from dart:convert, not dart:io. The dart:io library provides the WebSocket; the dart:convert library provides the serialization. The two are independent, and the choice of JSON is the application’s, not the WebSocket’s.
Complete Example Session
The following session builds a complete echo server and client, adds a structured message protocol, and demonstrates the ping interval and close handshake.
// ============================================
// PART 1: THE SERVER IMPORT AND BIND
// ============================================
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
// Bind the HTTP server
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 8080);
print('Server on ws://${server.address.address}:${server.port}');
// Accept every incoming request
await for (final request in server) {
if (WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
// Set the ping interval to detect dead clients
socket.pingInterval = const Duration(seconds: 30);
handleConnection(socket);
} else {
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
}
}
}
// ============================================
// PART 2: THE MESSAGE FORMAT
// ============================================
class Message {
final String type;
final Map<String, dynamic> payload;
final String? id;
Message({required this.type, required this.payload, this.id});
String encode() => jsonEncode({
'type': type,
'payload': payload,
if (id != null) 'id': id,
});
static Message decode(String raw) {
final map = jsonDecode(raw) as Map<String, dynamic>;
return Message(
type: map['type'] as String,
payload: map['payload'] as Map<String, dynamic>,
id: map['id'] as String?,
);
}
}
// ============================================
// PART 3: THE SERVER HANDLER
// ============================================
void handleConnection(WebSocket socket) {
print('Client connected: ${socket.remoteAddress.address}');
socket.listen(
(raw) {
try {
final msg = Message.decode(raw as String);
switch (msg.type) {
case 'echo':
socket.add(Message(type: 'echo', payload: msg.payload, id: msg.id).encode());
case 'ping':
socket.add(Message(type: 'pong', payload: {}, id: msg.id).encode());
default:
socket.add(Message(type: 'error', payload: {'reason': 'unknown'}, id: msg.id).encode());
}
} on FormatException catch (e) {
socket.add(Message(type: 'error', payload: {'reason': 'bad json: $e'}).encode());
}
},
onDone: () => print('Client disconnected'),
onError: (e) => print('Error: $e'),
);
}
// ============================================
// PART 4: THE CLIENT
// ============================================
import 'dart:convert';
import 'dart:io';
Future<void> clientMain() async {
// Connect to the server
final socket = await WebSocket.connect('ws://127.0.0.1:8080');
print('Connected. Protocol: ${socket.protocol}');
// Listen for responses
socket.listen(
(raw) {
final msg = Message.decode(raw as String);
print('Response: ${msg.type} ${msg.payload}');
},
onDone: () => print('Closed with code ${socket.closeCode}'),
onError: (e) => print('Error: $e'),
);
// Send an echo request with a correlation ID
socket.add(Message(
type: 'echo',
payload: {'text': 'hello'},
id: 'req-1',
).encode());
// Wait for the response, then close cleanly
await Future.delayed(const Duration(seconds: 1));
await socket.close(WebSocketStatus.normalClosure, 'done');
}
The four parts cover the server bind and upgrade, the message format, the server handler with error boundaries, and the client with a correlation ID and a clean close. What the session demonstrates is that the WebSocket is the transport and the application defines the protocol; the pingInterval is the liveness check; and the close handshake is the orderly termination.
Quick Reference
Core API
| Operation | Server | Client |
|---|---|---|
| Bind | HttpServer.bind(addr, port) | — |
| Connect | — | WebSocket.connect(url) |
| Upgrade | WebSocketTransformer.upgrade(request) | — |
| Check upgrade | WebSocketTransformer.isUpgradeRequest(request) | — |
| Send | socket.add(data) | socket.add(data) |
| Receive | socket.listen(callback) | socket.listen(callback) |
| Close | socket.close(code, reason) | socket.close(code, reason) |
WebSocket Properties
| Property | Meaning |
|---|---|
pingInterval | Interval for ping/pong liveness check |
readyState | Connection state (connecting, open, closing, closed) |
protocol | Negotiated subprotocol |
closeCode | Close status code after close |
closeReason | Close reason after close |
Message Types
| Dart Type | WebSocket Frame |
|---|---|
String | Text frame |
List<int> | Binary frame |
Common Close Codes
| Code | Constant | Meaning |
|---|---|---|
| 1000 | normalClosure | Normal closure |
| 1001 | goingAway | Endpoint going away |
| 1002 | protocolError | Protocol error |
| 1003 | unsupportedData | Unsupported data type |
Best Practices
✅ Do This:
// Check the upgrade before upgrading
if (WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
}
// Set a ping interval on the server
socket.pingInterval = const Duration(seconds: 30);
// Catch decode errors and respond with a structured error
try { Message.decode(raw); } on FormatException catch (e) { /* respond */ }
// Close cleanly with a code and reason
await socket.close(WebSocketStatus.normalClosure, 'done');
❌ Don’t Do This:
// Upgrade a request that is not a WebSocket handshake
final socket = await WebSocketTransformer.upgrade(request); // throws
// Forget the ping interval on the server
// Dead clients accumulate indefinitely
// Let decode exceptions close the connection
socket.listen((raw) => Message.decode(raw as String)); // unhandled error
// Assume a dropped TCP connection sends a close event
// It does not; the ping interval is the detector
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Upgrade throws | Request is not a WebSocket handshake | Check isUpgradeRequest first |
| Dead clients accumulate | No pingInterval set | Set pingInterval on the server |
| Decode error closes socket | Unhandled exception in listen | Wrap in try/catch |
Binary message arrives as List<int> | Client sent a binary frame | Handle both String and List<int> |
Close code is null | Connection dropped abruptly | Use pingInterval to detect |
Real-World Examples
1. Server upgrade
if (WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
}
2. Client connect
final socket = await WebSocket.connect('ws://127.0.0.1:8080');
3. Send text
socket.add('hello');
4. Send binary
socket.add([0x01, 0x02, 0x03]);
5. Listen
socket.listen((msg) => print(msg));
6. Ping interval
socket.pingInterval = const Duration(seconds: 30);
7. Close with code
await socket.close(WebSocketStatus.normalClosure, 'done');
8. JSON message
socket.add(jsonEncode({'type': 'echo', 'payload': {'text': 'hi'}}));
9. Decode with catch
try { final msg = Message.decode(raw); } on FormatException { /* error */ }
10. Handle done
socket.listen(onData, onDone: () => print('closed'));
Visual
Server Upgrade Flow
┌──────────────────────────────────────────────┐
│ HttpServer.bind(addr, port) │
│ │ │
│ ▼ │
│ Incoming HTTP request │
│ │ │
│ ▼ │
│ isUpgradeRequest? │
│ ├─ NO → 400 Bad Request │
│ └─ YES → WebSocketTransformer.upgrade() │
│ │ │
│ ▼ │
│ WebSocket │
│ add() / listen() / close() │
└──────────────────────────────────────────────┘
Client Connect Flow
┌──────────────────────────────────────────────┐
│ WebSocket.connect('ws://host:port') │
│ │ │
│ ▼ │
│ HTTP upgrade handshake │
│ │ │
│ ▼ │
│ Future<WebSocket> completes │
│ │ │
│ ▼ │
│ socket.add() / listen() / close() │
└──────────────────────────────────────────────┘
Ping/Pong Liveness
┌──────────────────────────────────────────────┐
│ pingInterval = 30s │
│ │ │
│ ▼ │
│ Every 30s: send ping │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ pong no pong │
│ │ │ │
│ ▼ ▼ │
│ alive close GOING_AWAY │
└──────────────────────────────────────────────┘
Message Routing
┌──────────────────────────────────────────────┐
│ Incoming text frame │
│ │ │
│ ▼ │
│ jsonDecode(raw) │
│ │ │
│ ▼ │
│ switch (message.type) │
│ ├─ 'echo' → echo payload │
│ ├─ 'ping' → pong │
│ └─ default → error │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Server bind | HttpServer.bind() |
| Upgrade check | WebSocketTransformer.isUpgradeRequest() |
| Upgrade | WebSocketTransformer.upgrade() |
| Client connect | WebSocket.connect() |
| Send | socket.add(data) |
| Receive | socket.listen(callback) |
| Liveness | socket.pingInterval |
| Close | socket.close(code, reason) |
| Text frame | String |
| Binary frame | List<int> |
Key takeaways:
dart:ioprovides the full WebSocket implementation. TheWebSocketclass,WebSocketTransformer, andHttpServerare enough for a pure Dart server and client. Theweb_socket_channelpackage is a cross-platform wrapper, not a requirement .- The server must check for the upgrade before upgrading.
WebSocketTransformer.isUpgradeRequestdistinguishes a WebSocket handshake from a normal HTTP request. Upgrading a non-upgrade request throws . - The
pingIntervalis the liveness detector. A dropped TCP connection does not produce a close event. The ping/pong exchange is the only reliable way to detect that the peer is gone . - The application defines the message format. The WebSocket carries text or binary; the structure—JSON, protobuf, or plain text—is the application’s decision.
- Decode errors must be caught. A malformed message should produce a structured error response, not an unhandled exception that closes the connection.
- The close handshake is orderly.
socket.close(code, reason)sends a close frame and waits for the peer’s response. ThecloseCodeandcloseReasonproperties are populated after the close completes .
Remember: A WebSocket is an HTTP upgrade followed by a raw TCP connection. The dart:io library handles the handshake, the framing, and the ping/pong liveness check. The server binds an HttpServer, checks each request for the upgrade header, and converts the upgrade to a WebSocket. The client connects with WebSocket.connect and receives a WebSocket once the handshake completes. Both sides send with add and receive with listen. The pingInterval is not optional on a server that accepts connections from the open internet, because a client that disappears without closing leaves a socket that the server believes is alive. The message format is the application’s protocol, and the error handling around the decode is the application’s responsibility. The WebSocket is the transport; the protocol is the design.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!