DEV Community

Roshan Melvin
Roshan Melvin

Posted on

Why your WebSocket messages silently vanish - the channel.ready trap in Dart

Why Your WebSocket Messages Silently Vanish - The channel.ready Trap in Dart

Tags: dart, flutter, websocket, networking


I was building native WebSocket support for API Dash - an open-source Flutter API client - as part of my GSoC 2026 proposal. The implementation looked clean. Connect, send, receive. I tested it against wss://echo.websocket.events, typed a message immediately after connecting, and watched the response feed. Nothing came back. No error. No exception. The send call returned successfully. The server just never saw my message.

This took me longer to debug than I'd like to admit. Here's exactly what's happening and how to fix it in one line.


The Three-Phase Handshake You're Skipping

Most developers think of a WebSocket connection as a single step. It isn't. Under the hood, establishing a usable WebSocket channel requires three sequential phases to complete:

Phase 1 - TCP Handshake
The standard three-way TCP handshake: SYN → SYN-ACK → ACK. Your OS handles this. Takes milliseconds on a local network, longer on mobile.

Phase 2 - TLS Handshake (if using wss://)
ClientHello → ServerHello → Certificate exchange → Finished. This alone can take 100–300ms on a cold connection. If you are connecting to any wss:// endpoint, this phase is mandatory before a single application byte can flow.

Phase 3 - HTTP Upgrade (RFC 6455 §4)
The WebSocket protocol itself begins as an HTTP/1.1 request. The client sends:

GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Enter fullscreen mode Exit fullscreen mode

The server must respond with 101 Switching Protocols before the connection becomes a true WebSocket channel. This is the handshake defined in RFC 6455 §4.

Only after all three phases complete is the channel in a readable and writable state.


Where Dart's WebSocketChannel.connect() Leaves You

Here is the trap. In Dart's web_socket_channel package:

final channel = WebSocketChannel.connect(uri);
// channel object exists HERE
// but the socket is NOT usable yet
channel.sink.add('hello'); // silently dropped
Enter fullscreen mode Exit fullscreen mode

connect() returns immediately after Phase 1 - after the TCP handshake. The channel object is constructed and handed to you. But Phase 2 (TLS) and Phase 3 (HTTP Upgrade) are still in progress asynchronously. When you write to channel.sink before the upgrade completes, the bytes enter the send buffer. The buffer does not throw. The buffer does not warn you. The server discards them because it is still processing the upgrade handshake and is not yet in WebSocket frame mode.

This is the worst kind of bug - it fails silently, returns no error, and only manifests under timing conditions that vary by network latency and TLS session reuse.


The Fix - One Line

The web_socket_channel package exposes a Future called channel.ready that resolves only after all three phases complete - TCP, TLS, and HTTP Upgrade.

final channel = WebSocketChannel.connect(uri);
await channel.ready; // waits for full RFC 6455 §4 handshake
// NOW safe to write
channel.sink.add('hello');
Enter fullscreen mode Exit fullscreen mode

That single await is the entire fix. In my implementation for API Dash, I gate the UI send button behind this future and attach the incoming stream listener only after it resolves:

Future<void> connect(String requestId, Uri uri) async {
  final channel = WebSocketChannel.connect(uri);
  try {
    await channel.ready;
    _channels[requestId] = channel;
    _emitStatus(requestId, WsStatus.connected);
    channel.stream.listen(
      (data) => _emitMessage(requestId, data, incoming: true),
      onError: (e) => _emitError(requestId, e),
      onDone: () => _emitStatus(requestId, WsStatus.disconnected),
    );
  } catch (e) {
    _emitError(requestId, e);
  }
}
Enter fullscreen mode Exit fullscreen mode

This guarantees zero dropped frames during TLS negotiation, and maps HTTP upgrade failures to readable error events instead of silent drops.


Why This Killed Previous API Dash WebSocket PRs

After tracing this bug I went back and read every closed WebSocket PR in the API Dash repository - #210, #215, #555. None of them awaited channel.ready. All of them reported the same symptom during review: messages sent immediately after connecting were unreliable. The reviewers attributed it to state management issues and closed the PRs. The actual root cause was this timing gap - a spec-level detail invisible unless you read RFC 6455 §4 carefully.

My implementation in PR #1529 fixes this at the connection manager level so the UI layer never has to think about handshake timing.


Key Takeaway

If you are using web_socket_channel in Dart or Flutter and your early messages are disappearing - add await channel.ready before your first write. One line. RFC 6455 §4. Done.

Top comments (0)