WebSocket
Build real-time WebSocket endpoints with the native, zero-dependency websocket middleware.
Celeris ships a native, zero-dependency WebSocket implementation (RFC 6455) in the
middleware/websocket package. You register it on a GET route, give it a
Handler, and Celeris takes care of the upgrade handshake, frame parsing,
masking, fragmentation, control frames, UTF-8 validation, and (optionally)
permessage-deflate compression. The same Handler runs unchanged whether you’re
on a native engine (epoll/io_uring) or the std engine — only the I/O plumbing
underneath differs.
Import it as:
import "github.com/goceleris/celeris/middleware/websocket"
websocketships in the coregithub.com/goceleris/celerismodule — no extra dependency to add.
WebSocket needs HTTP/1.1 connection takeover. HTTP/2 multiplexes streams over a single TCP connection, so it can’t be hijacked — the middleware returns 426 Upgrade Required for HTTP/2 upgrade requests (
celeris/middleware/websocket/websocket.go:69).
Echo-server quickstart
Register websocket.New on a GET route. Your Handler receives a
*websocket.Conn and should block until the connection is done — when it
returns, the connection is closed automatically.
package main
import (
"github.com/goceleris/celeris"
"github.com/goceleris/celeris/middleware/websocket"
)
func main() {
s := celeris.New(celeris.Config{Addr: ":8080"})
s.GET("/ws", websocket.New(websocket.Config{
Handler: func(c *websocket.Conn) {
for {
mt, msg, err := c.ReadMessage()
if err != nil {
return // peer closed, or read error → exit the loop
}
if err := c.WriteMessage(mt, msg); err != nil {
return
}
}
},
}))
s.Start()
}
That’s a complete RFC 6455 echo server. Handler is the only required field;
websocket.New panics at registration if it is nil
(celeris/middleware/websocket/config.go:170).
A few rules to internalise from the start:
- The middleware is a normal
HandlerFunc. Non-WebSocket requests are passed through to the next handler, so you can stack it with other middleware on the same route (celeris/middleware/websocket/websocket.go:64). - WebSocket routes are async by construction — registering
s.GET("/ws", websocket.New(cfg))is all you need. You do not (and should not bother to) call.Async(): it would have no effect here. Note that.Async()is a method on the*Routethats.GET(...)returns, not on theHandlerFuncthatwebsocket.Newreturns — sowebsocket.New(cfg).Async()does not compile. If you ever want to chain it explicitly, it goes on the route:s.GET("/ws", websocket.New(cfg)).Async()(still unnecessary). - Never force a WebSocket route to run inline. Handlers that take over the
connection are async by construction — do not call
.Sync()on the route or its group. See Routing. - When
Handlerreturns, Celeris sends a normal close frame (code1000) if you haven’t already and closes the connection (celeris/middleware/websocket/websocket.go:137).
Reading request data inside the handler
Route params, query params, and request headers are captured at upgrade time
and read off the *Conn (not a *Context):
// Route: /ws/:room
s.GET("/ws/:room", websocket.New(websocket.Config{
Handler: func(c *websocket.Conn) {
room := c.Param("room") // route parameter
token := c.Query("token") // query parameter ?token=...
ua := c.Header("user-agent")
_ = room
_ = token
_ = ua
// ...
},
}))
The Conn API
*websocket.Conn is the connection handle (celeris/middleware/websocket/conn.go:38).
All write methods (WriteMessage, WriteText, WriteBinary, WriteJSON,
WritePing, WriteControl) are internally serialised behind a single write lock,
so calling them from several goroutines concurrently is safe — each complete
message arrives intact. Reading, however, is single-goroutine: have at most one
goroutine in ReadMessage/ReadMessageReuse/NextReader at a time. The common
pattern is one read loop plus any number of writer goroutines
(celeris/middleware/websocket/doc.go:30-32).
Reading messages
| Method | Returns | Notes |
|---|---|---|
ReadMessage() (MessageType, []byte, error) | type, owned copy, error | Safe to retain/store/pass to other goroutines. |
ReadMessageReuse() (MessageType, []byte, error) | type, reused buffer, error | Zero-alloc; the slice is valid only until the next read call. |
ReadJSON(v any) error | error | Reads the next message and json.Unmarshals it into v. |
ReadMessage returns an owned copy of the payload — keep it, send it to
another goroutine, store it in a map, whatever. ReadMessageReuse hands back an
internal buffer that is overwritten on the next read; use it for echo servers and
forwarding proxies where you process the message immediately and don’t retain it
(celeris/middleware/websocket/conn.go:394, :411).
// Owned copy — safe to keep:
mt, msg, err := c.ReadMessage()
// Zero-alloc — process before the next read:
mt, msg, err := c.ReadMessageReuse()
// JSON decode straight off the wire:
var cmd struct{ Action string `json:"action"` }
if err := c.ReadJSON(&cmd); err != nil {
return
}
MessageType is one of websocket.TextMessage or websocket.BinaryMessage
(celeris/middleware/websocket/conn.go:29-33). Control frames (ping/pong/close)
are handled transparently by the read loop and never surface from
ReadMessage/ReadMessageReuse.
Writing messages
| Method | Sends |
|---|---|
WriteMessage(messageType MessageType, data []byte) error | A complete text or binary message. |
WriteText(data []byte) error | A text message (WriteMessage(TextMessage, …)). |
WriteBinary(data []byte) error | A binary message (WriteMessage(BinaryMessage, …)). |
WriteJSON(v any) error | json.Marshal(v) sent as a text message. |
WritePing(data []byte) error | A ping control frame. Keep data ≤ 125 bytes — this method does not validate the length (WriteControl does). |
WriteControl(messageType int, data []byte, deadline time.Time) error | A control frame with a per-frame deadline. Returns ErrControlTooLarge if data > 125 bytes. |
c.WriteText([]byte("hello"))
c.WriteBinary(blob)
c.WriteJSON(map[string]any{"type": "tick", "n": 42})
c.WritePing(nil)
If compression has been negotiated and a data payload is at least the compression
threshold, WriteMessage compresses it transparently — and only keeps the
compressed form if it actually came out smaller
(celeris/middleware/websocket/conn.go:591).
WriteControl is the right tool for pings/pongs/close from a separate goroutine
under load: it acquires the write lock with a deadline so a stalled large
write can’t block your keepalive forever, returning websocket.ErrWriteTimeout
if it can’t get the lock in time. Its messageType parameter is a plain int
that must hold a control opcode (websocket.OpClose, websocket.OpPing,
websocket.OpPong) — convert with int(...) because those constants are typed
websocket.Opcode — and data must be ≤ 125 bytes
(celeris/middleware/websocket/conn.go:718).
err := c.WriteControl(
int(websocket.OpPing),
[]byte("keepalive"),
time.Now().Add(5*time.Second),
)
if err == websocket.ErrWriteTimeout {
// couldn't grab the write lock in time
}
Ping / pong / close handlers
Override the default control-frame behaviour with the setter methods. Set them
before you start the read loop — they aren’t safe to swap mid-flight
(celeris/middleware/websocket/conn.go:992-1001).
| Method | Default behaviour |
|---|---|
SetPingHandler(func(data []byte) error) | Replies with a pong carrying the same payload. |
SetPongHandler(func(data []byte) error) | No-op. |
SetCloseHandler(func(code int, text string) error) | Echoes the close frame back and returns a *CloseError. |
The matching getters PingHandler(), PongHandler(), and CloseHandler()
return the current handler.
Keepalive with ping/pong
A classic pattern: send pings on a ticker, and reset the read deadline whenever a pong comes back. If the peer goes silent, the next read hits the deadline and the loop exits.
Handler: func(c *websocket.Conn) {
c.SetPongHandler(func(data []byte) error {
return c.SetReadDeadline(time.Now().Add(60 * time.Second))
})
_ = c.SetReadDeadline(time.Now().Add(60 * time.Second))
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := c.WritePing(nil); err != nil {
return
}
}
}()
for {
mt, msg, err := c.ReadMessage()
if err != nil {
return
}
_ = c.WriteMessage(mt, msg)
}
},
On native engines (epoll/io_uring)
SetReadDeadlineis a no-op (the underlyingnet.Connisn’t exposed) — see Engine behavior. For a deadline that works on every engine, configureIdleTimeoutinstead, which both paths honour.
Closing the connection
| Method | Behaviour |
|---|---|
GracefulClose(code int, text string) error | Sends a close frame and waits (up to ~5s) for the peer’s close response. |
Close() error | Closes the underlying connection immediately. |
// Polite shutdown: tell the peer why, then drain its close response.
c.GracefulClose(websocket.CloseNormalClosure, "bye")
You usually don’t need to call either — returning from Handler triggers an
automatic close-frame send and Close. Use GracefulClose when you want a clean
RFC 6455 closing handshake before exiting (celeris/middleware/websocket/conn.go:799).
Connection metadata
| Method | Returns |
|---|---|
NetConn() net.Conn | Underlying net.Conn on the std (hijack) path; nil on native engines. |
Context() context.Context | Connection context, cancelled when the connection closes. |
Subprotocol() string | The negotiated subprotocol, or "" if none. |
RemoteAddr() net.Addr | Peer address (nil when NetConn is nil). |
LocalAddr() net.Addr | Local address (nil when NetConn is nil). |
IP() string | Peer IP without port; result is cached after the first call. |
Param(key) string | Route parameter captured at upgrade time. |
Query(key) string | Query parameter captured at upgrade time. |
Header(key) string | Request header captured at upgrade time. |
Locals(key) any | Per-connection value (concurrency-safe). |
SetLocals(key, val) | Store a per-connection value (concurrency-safe). |
Locals/SetLocals give you a concurrency-safe per-connection bag — handy for
stashing an authenticated user ID at connect time and reading it during
broadcasts (celeris/middleware/websocket/conn.go:922-939).
Handler: func(c *websocket.Conn) {
c.SetLocals("userID", c.Query("uid"))
// ...later, from any goroutine...
uid := c.Locals("userID")
_ = uid
},
Deadlines and limits
| Method | Behaviour |
|---|---|
SetReadLimit(limit int64) | Maximum message size in bytes (default 64MB). Oversize messages close with 1009. |
SetReadDeadline(t time.Time) error | Per-read deadline. Returns nil (no-op) on native engines. |
SetWriteDeadline(t time.Time) error | Per-write deadline. Returns nil (no-op) on native engines. |
SetReadDeadline/SetWriteDeadline delegate to the underlying net.Conn, which
only exists on the std (hijack) path; on native engines they are no-ops
(celeris/middleware/websocket/conn.go:976, :985). For portable idle
enforcement use Config.IdleTimeout.
Runtime compression control
If compression was negotiated, you can toggle it per connection:
EnableWriteCompression(enable bool)— turn write compression on/off for this connection (only meaningful after negotiation).SetCompressionLevel(level int) error— set the flate level for subsequent writes; valid range-2…9, else returns an error (celeris/middleware/websocket/conn.go:1015-1027).
Streaming large messages
WriteMessage/ReadMessage buffer a whole message in memory. For payloads too
large to hold at once, stream them frame-by-frame:
| Method | Returns |
|---|---|
NextWriter(messageType MessageType) (io.WriteCloser, error) | A writer; each Write sends a frame, Close finalises the message. |
NextReader() (MessageType, io.Reader, error) | The next message as a streaming io.Reader. |
// Send a large body in chunks:
w, err := c.NextWriter(websocket.BinaryMessage)
if err != nil {
return
}
if _, err := io.Copy(w, src); err != nil {
_ = w.Close()
return
}
_ = w.Close() // Close sends the final (FIN) frame — required.
// Read a message as a stream:
mt, r, err := c.NextReader()
if err != nil {
return
}
_, _ = io.Copy(dst, r)
_ = mt
Rules from the source (celeris/middleware/websocket/writer.go:21-48,
celeris/middleware/websocket/reader.go:70-79):
- Only one writer may be active at a time. Calling
WriteMessage,WriteText,WriteBinary, orWriteJSONwhile aNextWriteris open returns an error (celeris/middleware/websocket/conn.go:595). Control frames (ping/pong/close viaWritePing/WriteControl) can still be sent concurrently. - You must call
Close()on the writer to send the final frame; until then the message is incomplete on the wire. - The
io.ReaderfromNextReaderis valid only until the nextNextReader,ReadMessage, orClosecall. - When compression is negotiated,
NextWriterbuffers the whole message and compresses onClose(permessage-deflate context spans the entire message).
Config
websocket.Config (celeris/middleware/websocket/config.go:46) is passed to
websocket.New. Only Handler is required.
| Field | Type | Default | Purpose |
|---|---|---|---|
Handler | func(*Conn) | — (required) | Runs after a successful upgrade; blocks until the connection is done. |
Skip | func(*celeris.Context) bool | nil | Skip the middleware for matching requests. |
SkipPaths | []string | nil | Skip exact paths (matched on c.Path()). |
CheckOrigin | func(*celeris.Context) bool | same-origin | Accept/reject the request origin. See Origin checking. |
Subprotocols | []string | nil | Server-supported subprotocols, in preference order. |
ReadBufferSize | int | 4096 | I/O read buffer size in bytes. |
WriteBufferSize | int | 4096 | I/O write buffer size in bytes. |
ReadLimit | int64 | 64MB | Maximum message size; oversize messages close with 1009. |
HandshakeTimeout | time.Duration | 0 (none) | Deadline for the upgrade handshake (std/hijack path). |
IdleTimeout | time.Duration | 0 (none) | Max time between messages before close; works on all engines. |
EnableCompression | bool | false | Negotiate permessage-deflate (RFC 7692). |
CompressionLevel | int | 1 (best speed) | Flate level, -2…9 (effective only when compression is enabled). |
CompressionThreshold | int | 128 | Minimum payload size in bytes before a message is compressed. |
MaxBackpressureBuffer | int | 256 | Inbound chunk buffer on the engine path; native engines only. |
BackpressureHighPct | int | 75 | Fill % at which the engine pauses inbound delivery. |
BackpressureLowPct | int | 25 | Fill % at which the engine resumes inbound delivery (must be < HighPct). |
OnConnect | func(*Conn) error | nil | Runs after upgrade, before Handler; a non-nil error closes the connection. |
OnDisconnect | func(*Conn) | nil | Runs after Handler returns. |
WriteBufferPool | BufferPool | nil | Pool write buffers across connections (std/hijack path only). |
Defaults are applied in applyDefaults (celeris/middleware/websocket/config.go:141).
Note CompressionLevel and CompressionThreshold defaults are only filled in
when EnableCompression is true.
Connect / disconnect hooks
OnConnect is your authorization checkpoint: it runs after the upgrade
completes but before Handler, and returning an error closes the connection
cleanly without ever invoking Handler. OnDisconnect always runs after
Handler returns — good for teardown like deregistering from a broadcast set.
websocket.New(websocket.Config{
OnConnect: func(c *websocket.Conn) error {
if c.Query("token") != "secret" {
return errors.New("unauthorized")
}
c.SetLocals("authed", true)
return nil
},
OnDisconnect: func(c *websocket.Conn) {
log.Printf("closed: %s", c.IP())
},
Handler: echo,
})
Compression (permessage-deflate)
Enable RFC 7692 compression with EnableCompression: true. It’s negotiated
during the handshake, so it only kicks in when the client also supports it.
Messages below CompressionThreshold (default 128 bytes) are sent uncompressed,
and even above the threshold the compressed form is only used if it’s actually
smaller.
websocket.New(websocket.Config{
EnableCompression: true,
CompressionLevel: websocket.CompressionLevelBestSpeed, // 1 (default)
CompressionThreshold: 256,
Handler: echo,
})
Compression-level constants (celeris/middleware/websocket/compress.go:11-19):
| Constant | Value | Meaning |
|---|---|---|
websocket.CompressionLevelHuffman | -2 | Huffman only |
websocket.CompressionLevelDefault | -1 | flate default |
websocket.CompressionLevelBestSpeed | 1 | best speed (default) |
websocket.CompressionLevelBestSize | 9 | best compression |
Backpressure (native engines)
On native engines the middleware keeps a bounded buffer of inbound chunks between
the engine’s event loop and your handler goroutine. When it fills past
BackpressureHighPct of MaxBackpressureBuffer, the engine pauses inbound
delivery for that connection, which closes the kernel’s TCP receive window and
slows the peer at the network level; when it drains below BackpressureLowPct,
delivery resumes (celeris/middleware/websocket/config.go:112-129). This is fully
automatic — the defaults (256 / 75% / 25%) suit most workloads.
You can observe health with Conn.BackpressureDropped(), which should always be
0 in normal operation. A non-zero value means the engine pause/resume mechanism
isn’t keeping up. It always returns 0 on the std (hijack) path, where the kernel
TCP stack handles backpressure directly (celeris/middleware/websocket/conn.go:887).
Write-buffer pooling (std/hijack path)
WriteBufferPool lets you share *bufio.Writer instances across connections to
cut per-connection memory for many idle sockets. It is only consulted on the
std (hijack) path — native engines pool their own write buffer internally and
ignore this setting (celeris/middleware/websocket/conn.go:206,
celeris/middleware/websocket/websocket.go:319). Implement the BufferPool
interface (Get(dst io.Writer) *bufio.Writer and Put(*bufio.Writer)); the
typical implementation wraps a sync.Pool and Reset(dst)s the writer on
borrow (celeris/middleware/websocket/config.go:28).
Broadcasting to many connections (Hub)
Real-time apps usually need to push one message to every connected client
(chat, live tickers, presence). websocket.Hub is the built-in fan-out
abstraction: register connections, broadcast to all (or a filtered subset),
unregister on disconnect. It encodes each frame once per broadcast and reuses
it across every connection, so the wire-encoding cost is O(1) regardless of
subscriber count (celeris/middleware/websocket/hub.go:56).
hub := websocket.NewHub(websocket.HubConfig{})
s.GET("/ws", websocket.New(websocket.Config{
Handler: func(c *websocket.Conn) {
unregister := hub.Register(c) // MUST be deferred
defer unregister()
for {
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
},
}))
// From any goroutine / publisher in your app:
hub.Broadcast(websocket.TextMessage, []byte(`{"type":"tick"}`))
Register returns an unregister function you must defer; it is safe to call
twice and a no-op after Hub.Close (celeris/middleware/websocket/hub.go:92).
| Method | Returns / behaviour |
|---|---|
NewHub(cfg HubConfig) *Hub | Construct a hub. |
Register(c *Conn) func() | Add a conn; returns the unregister func to defer. |
Broadcast(messageType MessageType, data []byte) (delivered int, err error) | Send to every registered conn. |
BroadcastFilter(mt MessageType, data []byte, pred func(*Conn) bool) (int, error) | Send only to conns where pred returns true. |
BroadcastPrepared(pm *PreparedMessage) (int, error) | Send an already-encoded message (see below). |
Len() int | Current registered-conn count. |
Close() | Unregister and close every conn; drains in-flight broadcasts first. |
Broadcast returns the number of connections reached and the first per-conn
error (if any). Authorize the connection before Register — the hub
broadcasts to every registered conn unfiltered.
Room / channel routing
Use BroadcastFilter with a predicate over Conn.Locals to target a subset
without maintaining a second hub (celeris/middleware/websocket/hub.go:153):
// On connect, tag the conn with its room:
c.SetLocals("room", c.Param("room"))
// Publish to one room only:
hub.BroadcastFilter(websocket.TextMessage, payload, func(c *websocket.Conn) bool {
return c.Locals("room") == "lobby"
})
Handling slow or dead connections
By default a conn whose write fails during a broadcast is unregistered and
closed. Override with HubConfig.OnSlowConn, returning a HubPolicy
(celeris/middleware/websocket/hub.go:10-37):
| Policy | Effect |
|---|---|
HubPolicyDrop | Skip this message; keep the conn registered. |
HubPolicyRemove | Unregister the conn but leave its lifecycle to you. |
HubPolicyClose | Unregister and close the conn. Default. |
hub := websocket.NewHub(websocket.HubConfig{
OnSlowConn: func(c *websocket.Conn, err error) websocket.HubPolicy {
return websocket.HubPolicyClose // boot misbehaving peers
},
})
Hub.Close waits for every in-flight broadcast to finish before tearing down
conns, so a shutdown path that synchronises on Close cannot race a still
fanning-out message (celeris/middleware/websocket/hub.go:315).
Bounding broadcast concurrency
A Broadcast fans out to every conn concurrently. HubConfig.MaxConcurrency
caps how many of those per-conn writes run at once via a semaphore, keeping
goroutine pressure bounded on very large hubs
(celeris/middleware/websocket/hub.go:46). Leave it 0 (the default) to use
DefaultHubConcurrency() — runtime.GOMAXPROCS(0) * 4
(celeris/middleware/websocket/hub.go:54):
hub := websocket.NewHub(websocket.HubConfig{
MaxConcurrency: 256, // cap in-flight per-conn writes during a Broadcast
})
Reusing an encoded message
When you publish the same payload repeatedly, build a PreparedMessage once and
reuse it. The frame is encoded a single time (with separate cached
uncompressed/compressed variants) and shared across every send
(celeris/middleware/websocket/prepared.go:9-54):
pm, err := websocket.NewPreparedMessage(websocket.TextMessage, payload)
if err != nil {
return
}
hub.BroadcastPrepared(pm) // fan-out
// or, per-connection:
_ = conn.WritePreparedMessage(pm)
NewPreparedMessage rejects control opcodes (ping/pong/close) and returns
websocket.ErrInvalidPreparedOpcode — control frames have RFC 6455 §5.5 size
constraints the cache-and-reuse model can’t honour; use WriteControl
per-connection for those (celeris/middleware/websocket/prepared.go:42).
Origin checking
By default the middleware enforces a same-origin policy: the request’s
Origin header host must match the Host header
(celeris/middleware/websocket/websocket.go:84). This protects against
cross-site WebSocket hijacking (CSWSH) from a malicious page. The exact default
behaviour:
Originpresent but cross-origin → 403.Originmissing on anhttpsrequest → 403 (treated as a CSRF-class signal).Originmissing on plainhttp→ allowed (loopback dev tools and CLI clients commonly omit it).
Override it with CheckOrigin, which returns true to accept the request:
// Allow all origins (only if you really mean it — disables CSWSH protection):
websocket.New(websocket.Config{
CheckOrigin: func(c *celeris.Context) bool { return true },
Handler: echo,
})
// Restrict to an allow-list:
allowed := map[string]bool{
"https://app.example.com": true,
"https://admin.example.com": true,
}
websocket.New(websocket.Config{
CheckOrigin: func(c *celeris.Context) bool {
return allowed[c.Header("origin")]
},
Handler: echo,
})
When CheckOrigin is set, the built-in same-origin logic is bypassed entirely —
your function is the sole gate. A rejected origin yields 403 with body
websocket: origin not allowed (celeris/middleware/websocket/websocket.go:82).
Don’t reflexively allow all origins.
func(*celeris.Context) bool { return true }disables the browser’s only built-in defence against another site opening an authenticated socket to your server. Prefer an explicit allow-list, or pair an allow-all policy with a token check inOnConnect.
Close handling
When the peer sends a close frame, the read loop returns a *CloseError from the
default close handler (celeris/middleware/websocket/conn.go:1029):
type CloseError struct {
Code int
Text string
}
Classify it with the helpers:
| Function | Returns true when… |
|---|---|
IsCloseError(err error, codes ...int) bool | err is a *CloseError whose code is in codes. |
IsUnexpectedCloseError(err error, expected ...int) | err is a *CloseError whose code is not in expected. |
FormatCloseMessage(code int, text string) []byte | Builds a close-frame payload (code + text) for WriteControl. |
for {
_, msg, err := c.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
) {
log.Printf("abnormal close: %v", err)
}
return
}
_ = msg
}
The standard RFC 6455 close codes are exported as constants
(celeris/middleware/websocket/opcode.go:28-43):
| Constant | Code | Meaning |
|---|---|---|
CloseNormalClosure | 1000 | Normal closure |
CloseGoingAway | 1001 | Endpoint going away |
CloseProtocolError | 1002 | Protocol error |
CloseUnsupportedData | 1003 | Unacceptable data type |
CloseNoStatusReceived | 1005 | No status code (reserved) |
CloseAbnormalClosure | 1006 | Abnormal closure (reserved) |
CloseInvalidPayload | 1007 | Invalid payload data |
ClosePolicyViolation | 1008 | Policy violation |
CloseMessageTooBig | 1009 | Message too big |
CloseMandatoryExt | 1010 | Mandatory extension missing |
CloseInternalError | 1011 | Internal server error |
CloseServiceRestart | 1012 | Service restart |
CloseTryAgainLater | 1013 | Try again later |
Send a specific close code to the peer with GracefulClose (which also waits for
the peer’s response) or with WriteControl + FormatCloseMessage:
_ = c.WriteControl(
int(websocket.OpClose),
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "too noisy"),
time.Now().Add(time.Second),
)
Engine behavior
The same Handler runs on every engine, but the I/O underneath differs in ways
worth knowing (celeris/middleware/websocket/doc.go:41-44):
| Aspect | Native engines (epoll, io_uring) | std engine (hijack) |
|---|---|---|
| Connection ownership | Stays in the event loop; reads delivered to the handler goroutine. | Connection is hijacked; handler reads/writes directly. |
NetConn() | Returns nil. | Returns the live *net.TCPConn. |
SetReadDeadline / SetWriteDeadline | No-op (returns nil). | Applied to the underlying socket. |
IdleTimeout | Enforced via the engine’s idle sweep. | Enforced via net.Conn.SetReadDeadline. |
| Backpressure | Engine-integrated (TCP receive window); see Backpressure. | Handled by the kernel TCP stack. |
WriteBufferPool | Ignored (engine pools its own write buffer). | Consulted. |
The practical takeaways:
- Don’t rely on
NetConn()orSetReadDeadline/SetWriteDeadlinefor portable code. They only do something on the std engine. For an idle timeout that works everywhere, setConfig.IdleTimeout— both paths converge to the same observable behaviour (celeris/middleware/websocket/config.go:104-110). - Backpressure tuning only matters on native engines. On the std path the relevant fields are simply ignored.
The low-level alternative
For advanced cases the *celeris.Context exposes the primitives the middleware
is built on. Reach for these only when the middleware doesn’t fit — most
applications never need them.
| Method | Use |
|---|---|
c.Hijack() (net.Conn, error) | Take over the raw TCP connection (HTTP/1.1 only; HTTP/2 fails). |
c.UpgradeWebSocket(delivery func([]byte)) bool | Engine-integrated upgrade; false if the engine doesn’t support it. |
Hijack hands you the raw net.Conn and makes you responsible for the
handshake, framing, and closing it (celeris/context_response.go:1256).
UpgradeWebSocket installs a data-delivery callback for the native engine path
and returns false on the std engine, where you fall back to Hijack
(celeris/context_response.go:1290). Driving these correctly means
re-implementing RFC 6455 yourself — the websocket middleware exists precisely so
you don’t have to. For other long-lived response patterns built on the same
primitives, see Streaming responses.
Common pitfalls
Handlermust block. Returning early closes the connection. Run your read loop (and any keepalive ticker) inside the handler and only return when you want to disconnect.Handler: nilpanics at startup. It’s the one required field (celeris/middleware/websocket/config.go:170).- Never
.Sync()a WebSocket route. WebSocket handlers are async by construction; forcing them inline breaks them (see Routing). ReadMessageReuseslices are transient. The returned buffer is overwritten on the next read — copy it (or useReadMessage) if you keep it past the next read call.- Allow-all origins disables CSWSH protection. Prefer an allow-list or a token
check in
OnConnect. NetConn()/SetReadDeadlinearenil/ no-ops on native engines. UseConfig.IdleTimeoutfor a portable idle timeout.WriteControldata is capped at 125 bytes and the opcode must be a control opcode, or it returns an error (celeris/middleware/websocket/conn.go:718).
FAQ
Why am I getting a 426 response?
The request arrived over HTTP/2, which can’t be hijacked. WebSocket requires
HTTP/1.1 (celeris/middleware/websocket/websocket.go:69).
Why does my upgrade return 403?
The default same-origin check rejected it — either the Origin host didn’t match
Host, or Origin was missing on an https request. Set Config.CheckOrigin
for non-browser clients or cross-origin setups
(celeris/middleware/websocket/websocket.go:84).
Can I read and write from different goroutines? Yes. One goroutine may read while others write — all write methods are internally serialised. Just don’t have two goroutines reading, or two writing the same message at once.
How do I cap message size?
Set Config.ReadLimit (or call Conn.SetReadLimit at runtime). The default is
64MB; oversize messages close the connection with code 1009
(celeris/middleware/websocket/config.go:74).
How do I time out idle connections on every engine?
Set Config.IdleTimeout. Unlike SetReadDeadline, it’s honoured on both the
native and std paths (celeris/middleware/websocket/config.go:104).
Does the middleware send a close frame for me?
Yes — when Handler returns, Celeris sends a 1000 close frame (if you haven’t
already) and closes the connection. Use GracefulClose if you want to send a
specific code and wait for the peer’s close response.
See also
- Streaming responses — incremental output and the low-level
StreamWriter/Detach/Hijackprimitives the upgrade is built on. - Server-Sent Events — one-way push when you don’t need full duplex.
- Routing — registering the
GETroute and why WebSocket routes are async. - Middleware — composing the WebSocket middleware with the rest of your stack.