skip to content

With web_socket_channel, how do you close a socket with a status code and reason, and how do you read why the server closed it?

level: middleimportance: should knowfreq 30%

answer

  1. the sink takes extra arguments
  2. sink.close([closeCode, closeReason])
  3. client codes: 1000 or 3000-4999
  4. reason at most 123 bytes UTF-8
  5. closeCode null until closed

basics

~20 s

Call channel.sink.close(code, reason), where the code must be 1000 or 3000-4999 and the reason at most 123 UTF-8 bytes. After the stream's onDone, channel.closeCode and channel.closeReason hold what the server sent; both are null before that.

solid answer

~40 s

`channel.sink` is a `WebSocketSink`, a `StreamSink` whose `close([int? closeCode, String? closeReason])` puts a code and reason into the Close frame; with no code the peer sees 1005, 'no status received'. In version 3 the adapter delegates to `package:web_socket`, which accepts only 1000 or an application code in 3000-4999 and a reason up to 123 bytes of UTF-8. Anything else, including the package's own `status.goingAway` (1001), is rejected with an `ArgumentError` that surfaces asynchronously, not from the `close()` call, and the socket is not closed with it. To learn why the server closed, I read `channel.closeCode` and `channel.closeReason` in the subscription's `onDone`; before the connection ends they are `null`.

code

dart · 24 lines
dart
import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/web_socket_channel.dart';

const matchFinished = 4001;
const tokenExpired = 4002;

void watch(WebSocketChannel channel, void Function() reconnect) {
  channel.stream.listen(
    (message) {/* decode and publish */},
    onDone: () {
      switch (channel.closeCode) {
        case matchFinished:
          return; // nothing more to receive
        case tokenExpired:
          reconnect(); // after refreshing credentials
        default:
          reconnect(); // dropped or unexpected: back off and retry
      }
    },
  );
}

Future<void> leave(WebSocketChannel channel) =>
    channel.sink.close(status.normalClosure, 'screen closed');

go deeper

for a junior

Recall that the sink's close method takes an optional code and reason, and that closeCode and closeReason tell you how the connection ended.

for a middle

Explain which codes the version 3 client accepts, the 123-byte reason limit, and why closeCode is only meaningful in onDone.

for a senior

Design application close codes with the backend so the client can tell 'stop', 'refresh credentials' and 'reconnect' apart.

for a principal

Treat close codes as part of the client-server contract, versioned and documented, so old app builds react sensibly to new server decisions.

## Closing from the client `WebSocketChannel.sink` returns a **`WebSocketSink`**. It behaves like any `StreamSink` (`add`, `addError`, `addStream`, `close`, `done`) except that **`close([int? closeCode, String? closeReason])`** takes two optional arguments that go into the WebSocket Close frame. What each numeric code *means* is protocol material; what matters in Flutter is which values this package lets a client send and how you read the server's. ## Which codes a client may send web_socket_channel 3 routes `WebSocketChannel.connect` and `IOWebSocketChannel` through an adapter over **`package:web_socket`**. Its `close` validates the arguments before anything is sent: - **code**: `null`, **1000**, or **3000-4999**; anything else throws `ArgumentError` ("close code must be 1000 or in the range 3000-4999"); - **reason**: at most **123 bytes** when encoded as UTF-8, otherwise `ArgumentError`; - **no code at all**: the peer sees **1005**, "no status received", and no reason. The package also ships `package:web_socket_channel/status.dart`, constants such as `normalClosure` (1000), `goingAway` (1001) and `policyViolation` (1008), and its README example closes with `status.goingAway`. With the version 3 adapter that example is rejected by the validation above. The source wins over the README: use `status.normalClosure` or a 3000-4999 code your server defines. ## How the rejection shows up The validation happens late. `sink.close(1001)` only stores the code and closes the sink; the adapter then calls the underlying `close` from a stream `onDone` callback. The `ArgumentError` is thrown there, not from your call, so: 1. the `sink.close(1001)` call does not throw the `ArgumentError` itself; 2. the error is reported as an **unhandled asynchronous error**; 3. the connection is **not closed** with that code. That is why a bad close code looks like "close() does nothing" in bug reports. ## Reading why the server closed `WebSocketChannel` exposes **`closeCode`** (`int?`) and **`closeReason`** (`String?`). Both are **`null` until the connection has closed**; the adapter fills them when the Close frame arrives, just before the stream finishes. The reliable place to read them is therefore the subscription's **`onDone`**: | Observed in `onDone` | Likely meaning for the app | |---|---| | a 1000 code | orderly close; reconnect only if the app still needs data | | a 4000-range code your server defines | an application decision such as "match finished" or "token expired" | | a 1008-style policy code | the server refused the session; fix the cause before retrying | | `null` or an abnormal code | the connection dropped without an orderly close; reconnect with backoff | Agreeing on 4000-range codes with the backend is a cheap way to make reconnect logic smart: "match finished" should not trigger a reconnect loop, "token expired" should refresh credentials first. ## Where the codes come from in a scores feed A live-scores backend has several legitimate reasons to end a connection, and each deserves a different client reaction: - the **match ends**: the server closes with an agreed code, the client shows the final score and stops; - the **server is draining** for a deploy: an agreed code tells clients to reconnect after a short random delay, landing on a healthy instance; - the **session's credential expired**: the client refreshes it before reconnecting, rather than failing in a loop; - the **client misbehaved**, for example flooding subscribe messages: a policy code, logged, with no automatic retry. Without agreed codes, all four look the same to the client: the stream ended. With them, `onDone` becomes a small `switch` and the reconnect loop stays simple. The same list works in the other direction: the client closing with `status.normalClosure` on dispose tells the server the user left on purpose, which keeps its connection metrics honest. ## In practice - Close with `status.normalClosure` when a screen or repository is disposed. - Define a small enum of application close codes shared with the server, in the 4000-4999 range, and switch on `closeCode` in `onDone`. - Keep reasons short and non-sensitive: they are for logs, and the 123-byte limit applies.

  • Why does sink.close(status.goingAway) appear to do nothing in web_socket_channel 3?
    The version 3 adapter validates codes in `package:web_socket`, which accepts only 1000 or 3000-4999. The check runs when the adapter closes the underlying socket, after your `close()` call has handed off, so the `ArgumentError` surfaces as an unhandled async error instead of from your call, and the socket is not closed with that code. Use `status.normalClosure` or an application code.
  • When are closeCode and closeReason safe to read?
    After the connection has ended. Before that both are `null`. The adapter sets them when the Close frame arrives and then closes the stream, so the subscription's `onDone` callback is the natural place to read them.

saying these in an interview costs you the question

  • Any status constant from status.dart is valid for a client to send
  • closeCode holds the last close code even while the socket is open
  • sink.close() throws synchronously when the code is invalid
  • Close reasons can carry a full error payload of any length
  • Calling sink.close() without a code sends 1000 to the server