Skip to content

Close codes and shutdown

Gerasimos (Makis) Maropoulos edited this page Oct 6, 2026 · 1 revision

When to use this page

  • You want to close a connection with a code and a reason the other side can read, instead of dropping it with no explanation.
  • You're deciding between closing one connection and shutting the whole server down.
  • You want a client (Go or JavaScript) to tell a deliberate close apart from a dropped network, and react differently.

Close frames

A websocket connection ends with a close frame: a numeric code plus an optional human-readable reason, sent by whichever side closes first. neffos exposes the code and reason on both sides, lets you choose them when closing from your own code, and reads them back out of any error with neffos.CloseStatus.

The codes

const (
	CloseNormalClosure           = 1000
	CloseGoingAway               = 1001
	CloseProtocolError           = 1002
	CloseUnsupportedData         = 1003
	CloseNoStatusReceived        = 1005
	CloseAbnormalClosure         = 1006
	CloseInvalidFramePayloadData = 1007
	ClosePolicyViolation         = 1008
	CloseMessageTooBig           = 1009
	CloseMandatoryExtension      = 1010
	CloseInternalServerErr       = 1011
	CloseServiceRestart          = 1012
	CloseTryAgainLater           = 1013
	CloseTLSHandshake            = 1015
)

These come from RFC 6455 and the IANA registry; neffos uses a handful of them itself (CloseGoingAway on shutdown, CloseMessageTooBig on an oversized message, ClosePolicyViolation as the default for a plain error returned from OnConnect). The range 4000 to 4999 is reserved for applications: pick any code in it for your own meanings (a ban, a kick, a rate limit), the way _examples/01-getting-started/09-close-and-shutdown uses 4000 for "kicked by operator".

Closing with a code and a reason

From inside an event handler (or Server.OnConnect), return a neffos.CloseError instead of a plain error to choose the close code:

neffos.Events{
	"chat": func(c *neffos.NSConn, msg neffos.Message) error {
		if isBanned(c.Conn) {
			return neffos.CloseError{Code: 4003, Reason: "banned"}
		}
		// ...
		return nil
	},
}
type CloseError struct {
	Code   int
	Reason string
}

CloseError{Code: 1008} alone is a valid literal; Error() renders as "[Code] text", and Unwrap() exposes any underlying error so errors.Is/errors.As still see through it. A plain (non-CloseError) error returned from OnConnect closes with ClosePolicyViolation (1008); return a CloseError yourself to pick a different code.

From outside a handler, close a specific connection directly:

c.Terminate(4000, "kicked by operator")
func (c *Conn) Terminate(code int, reason string)
func (c *Conn) Close() // Terminate(CloseNormalClosure, "")

Close() is shorthand for a normal, no-reason close; Terminate is what you want whenever the code or the reason matters. Both are idempotent: a second call on an already-closed connection does nothing.

Reading the code back

On the side that stayed open, Conn.Err() holds whatever closed the other one, and neffos.CloseStatus turns it into a number. A peer that drops with a clean FIN and never sends a close frame reports neffos.CloseAbnormalClosure (1006) on all three backends, so that case still has a code. CloseStatus returns -1 only when there was no code at all: a read or write timeout, a heartbeat failure, or a hard TCP reset.

// Server-side, in OnDisconnect:
srv.OnDisconnect = func(c *neffos.Conn) {
	log.Printf("[%s] disconnected, close status %d", c.ID(), neffos.CloseStatus(c.Err()))
}
// Client-side, once NotifyClose fires:
<-client.NotifyClose
code := neffos.CloseStatus(client.Conn().Err())

Conn.Err() is set before NotifyClose fires, so reading it right after the channel closes is race-free. See _examples/01-getting-started/09-close-and-shutdown for both sides of this together: the server logging CloseStatus(c.Err()) in OnDisconnect, and the kicked client printing the code and reason it got from client.Conn().Err().

From JavaScript

conn.onclose = () => {
    console.log(conn.closeInfo.code, conn.closeInfo.reason);
};

conn.closeInfo ({ code, reason, wasClean, error? }) describes how the socket last closed; it is undefined while the connection is open. A pending ask or an in-flight connect that the connection drops out from under rejects with ErrClosed, which neffos.isCloseError does not recognize by itself (it only matches a CloseError or the literal "[-1] write closed" text); check err === neffos.ErrClosed || neffos.isCloseError(err) to tell a close-triggered rejection apart from a domain error returned by a handler. See Errors for the rest of the error helpers.

Shutting the whole server down

func (s *Server) Close()
func (s *Server) Shutdown(ctx context.Context) error

Both close every connection with CloseGoingAway (1001) and a reason ("server closed" or "server shutting down"), at the same time, not one by one. The difference is what they wait for:

  • Close() waits for each connection's OnNamespaceDisconnect/OnRoomLeave callbacks and the server's own OnDisconnect to run, then signals the dispatch loop to stop and releases the StackExchange (if one implements StackExchangeCloser). It does not wait for any event callback still running elsewhere.
  • Shutdown(ctx) does everything Close does, and then also waits for every connection's reader goroutine, including any event callback it is in the middle of running, until ctx is done. It returns ctx.Err() if the deadline passes first, otherwise nil. Call it with a bounded context: context.WithTimeout(context.Background(), 5*time.Second) is what the examples use.

Neither should be called from inside an event callback, OnConnect, or OnDisconnect: that callback is one of the things Shutdown is waiting for, so calling it from there waits on itself.

The usual shape, from _examples/01-getting-started/09-close-and-shutdown, pairs signal.NotifyContext with both Server.Shutdown and the standard library's http.Server.Shutdown, in that order:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

// ... start the HTTP server in a goroutine ...

<-ctx.Done()
stop()

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
	log.Printf("websocket shutdown: %v", err)
}
if err := httpServer.Shutdown(ctx); err != nil {
	log.Printf("http shutdown: %v", err)
}

http.Server.Shutdown goes after, not before: it does not track a hijacked websocket connection as one of its own, so it would return immediately without having closed a single neffos socket if called first.

Reconnection policy by code

The close code a client receives is also the input to whether it should try reconnecting at all. On the JavaScript client, ReconnectOptions.shouldReconnect(info) gets the same CloseInfo described above and decides:

reconnect: {
    shouldReconnect: (info) => info.code !== 4001, // 4001: don't come back
}

Server.Shutdown/Server.Close using CloseGoingAway (1001) specifically is what makes this useful: a shouldReconnect that checks info.code can tell "the server is shutting down on purpose" apart from a dropped network, and choose not to reconnect into a server that is going away anyway (or, just as validly, choose to keep retrying because a restart is expected). See Reconnection for the full reconnect lifecycle, defaults and backoff.

Example

_examples/01-getting-started/09-close-and-shutdown: kick <name> closes one connection with code 4000, Ctrl+C runs Server.Shutdown, and both sides read the resulting code back with CloseStatus.

See also

  • Errors, IsCloseError/IsDisconnectError/IsTimeoutError and the known-error registry
  • Reconnection, what a client does after a close, and when it decides not to
  • Timeouts, the heartbeat and size-limit closes that land here too
  • Architecture, where the close handshake fits in the connection lifecycle

Clone this wiki locally