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?
answer
- the sink takes extra arguments
- sink.close([closeCode, closeReason])
- client codes: 1000 or 3000-4999
- reason at most 123 bytes UTF-8
- closeCode null until closed
basics
~20 sCall 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 linesimport '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
Recall that the sink's close method takes an optional code and reason, and that closeCode and closeReason tell you how the connection ended.
Explain which codes the version 3 client accepts, the 123-byte reason limit, and why closeCode is only meaningful in onDone.
Design application close codes with the backend so the client can tell 'stop', 'refresh credentials' and 'reconnect' apart.
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