Adler is a lightweight WebSocket server toolkit for Go. It gives you a small, focused API for upgrading HTTP requests, handling sessions, broadcasting messages, and organizing clients into rooms.
Built on gws — a high‑performance, RFC 6455‑compliant WebSocket library with zero‑alloc reads and aggressive buffer reuse.
Migrated from gobwas/ws → gws. The original gobwas/ws implementation is preserved at tags
v1.4.1and thegobwas/wsbranch. The switch was driven by gws's superior throughput (~7.8M msg/s vs ~2.5M msg/s) and zero‑alloc read path, which eliminated GC pressure from frame parsing under high concurrency.
- WebSocket upgrade from
net/http - High‑performance gws engine with zero‑alloc reads and ~7.8M msg/s throughput
- Session lifecycle hooks
- Text, binary, and JSON messaging
- Protocol-aware JSON or Protobuf serialization (
Write,BroadcastAny) - Global broadcast, filtered broadcast, and targeted session sends
- Rooms with join, leave, and room-level broadcast helpers
- Configurable inbound message limits and pong-based connection timeouts
- Per-session key-value storage
- Optional access to the underlying request, protocol, and connection
- Add a dedicated matchmaker module for queue creation, queue management, and match orchestration.
- Replace gobwas/ws with gws for high-throughput, zero-alloc WebSocket engine.
- Add ELO-based matchmaking with configurable rating buckets and match quality rules.
go get github.com/catalinfl/adlerpackage main
import (
"log"
"net/http"
"github.com/catalinfl/adler"
)
func main() {
a := adler.New()
a.HandleConnect(func(s *adler.Session) {
log.Println("connected:", s.RemoteAddr())
})
a.HandleMessage(func(s *adler.Session, msg []byte) {
_ = s.WriteText([]byte("echo: " + string(msg)))
})
a.HandleError(func(s *adler.Session, err error) {
log.Println("ws error:", err)
})
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
if err := a.HandleRequest(w, r); err != nil {
log.Println("handle request:", err)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}Use adler.New() to build a server instance. You can pass configuration options at construction time:
// Import "time" for duration options.
a := adler.New(
adler.WithDispatchAsync(true),
adler.WithMessageBufferSize(256),
adler.WithPingPeriod(30*time.Second),
)Handlers are set on the Adler instance and are called during the session lifecycle.
HandleConnectruns after a session is registered.HandleMessagereceives text frames.HandleMessageBinaryreceives binary frames.HandlePongreceives pong frames.HandleClosereceives close code and reason.HandleSentMessageandHandleSentMessageBinaryrun after server writes succeed.HandleErrorreceives runtime errors from the session loop.OnRoomJoinandOnRoomLeavereceive room membership events.
Example:
a.HandleConnect(func(s *adler.Session) {
s.Set("userID", "123")
})
a.HandleClose(func(s *adler.Session, code int, reason string) {
log.Printf("client closed: code=%d reason=%q", code, reason)
})Call HandleRequest from an HTTP handler. It upgrades the connection and blocks until the session ends.
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
_ = a.HandleRequest(w, r)
})Session is the object you receive in callbacks. It exposes message writers and a small key-value store.
Messaging helpers:
Write(any)(protocol-aware JSON text or Protobuf binary)WriteText([]byte)WriteTextWithDeadline([]byte, time.Duration)WriteBinary([]byte)WriteBinaryWithDeadline([]byte, time.Duration)WriteJSON(adler.Map)WriteJSONWithDeadline(any, time.Duration)Close(...[]byte)
Protocol-aware helpers (Write, BroadcastAny, Room.BroadcastAny) use the server
protocol (JSON by default). Set adler.WithProtocol(adler.Protobuf) to emit
protobuf binary frames.
Storage helpers:
Set(key, value)SetNX(key, value)Get(key)GetString(key)GetInt(key)GetInt64(key)GetFloat(key)GetBool(key)Has(key)Unset(key)Keys()Values()Clear()Incr(key)andDecr(key)for*int64counters
Metadata:
Request()returns the original HTTP requestProtocol()returns the HTTP protocol string used during upgradeLocalAddr()andRemoteAddr()expose the connection addressesRoom()returns the current roomUnsafeConn()exposes the raw network connection when you need low-level control
Example session storage:
a.HandleConnect(func(s *adler.Session) {
s.Set("role", "admin")
s.SetNX("seen", true)
})
a.HandleMessage(func(s *adler.Session, msg []byte) {
role, _ := s.GetString("role")
_ = s.WriteText([]byte("role=" + role + " msg=" + string(msg)))
})Use server-level broadcast helpers when you want to send to multiple sessions.
Broadcast([]byte)sends text to all connected sessionsBroadcastFilter([]byte, func(*Session) bool)sends text to matching sessionsBroadcastBinary([]byte)sends binary to all connected sessionsBroadcastBinaryFilter([]byte, func(*Session) bool)sends binary to matching sessionsBroadcastJSON(adler.Map)broadcasts JSONBroadcastJSONFilter(adler.Map, func(*Session) bool)broadcasts JSON to matching sessionsBroadcastAny(any)broadcasts using the configured protocolBroadcastOthers([]byte, *Session)sends to everyone except the target sessionSendTo([]byte, *Session)sends only to one session
Example:
a.Broadcast([]byte("server says hello"))
a.SendTo([]byte("private message"), session)
a.BroadcastFilter([]byte("admins only"), func(s *adler.Session) bool {
role, _ := s.GetString("role")
return role == "admin"
})Rooms help you manage subsets of sessions.
room := a.NewRoom("lobby")
a.HandleConnect(func(s *adler.Session) {
_ = room.Join(s)
})
room.Broadcast([]byte("welcome to the lobby"))Room helpers:
NewRoom(name)returns an existing room or creates oneDeleteRoom(name)removes a room manually when it is emptyName()returns the room nameLen()returns the number of membersSessions()returns a snapshot of current membersJoin(*Session)adds a session to the roomLeave(*Session)removes a sessionOpenRoom()allows joins againCloseRoom()blocks new joinsBroadcast,BroadcastBinary,BroadcastFilter,BroadcastBinaryFilter,BroadcastJSON,BroadcastJSONFilter,BroadcastAnyBroadcastBinaryOther([]byte, *Session)sends binary to everyone except the target session
With WithDeleteRoomOnEmpty(true) (the default), a room is removed when its last
member leaves or disconnects. Leave callbacks run before empty-room cleanup,
and cleanup completes before HandleDisconnect. Use WithDeleteRoomOnEmpty(false)
to retain empty rooms, for example when keeping a fixed set of chat channels.
The matchmaker module provides queue-based matchmaking that groups sessions into rooms automatically. It uses a background goroutine to manage queues and ensure fair player distribution.
Features:
- Non-blocking queue operations:
AddToQueue()andRemoveFromQueue()send commands to a worker goroutine - Main and waiting queues: Keeps a main queue and an optional waiting queue when the main queue reaches capacity
- Automatic room creation: Creates Adler rooms when enough players are queued
- JSON event notifications: Sends events to sessions as they move through the queue
Matchmaker queue events are defined in matchmaker/matchmaking.proto and
emitted as protobuf QueueStatus messages. When the Adler protocol is JSON (default),
encoding/json marshals the generated Go oneof structure using a Payload envelope:
Payload.QueueJoined- Session added to main queuePayload.WaitQueueJoined- Session added to waiting queue (main is full)Payload.PromotedToQueue- Session promoted from waiting to main queuePayload.MatchFound- Match room created withroom_idand player countPayload.QueueError- Queue operation error
For example, a successful match is sent as:
{"Payload":{"MatchFound":{"room_id":"room_...","players":4}}}This is the generated Go JSON representation, not the legacy flat type event
format or the protojson representation. Protobuf mode sends binary QueueStatus
messages instead.
Example:
import "github.com/catalinfl/adler/matchmaker"
mm := matchmaking.NewMatchmaker(a,
matchmaking.WithRoomSize(4),
matchmaking.WithQueueLength(20),
)
a.HandleConnect(func(s *adler.Session) {
if err := mm.AddToQueue(s); err != nil {
log.Printf("enqueue matchmaking: %v", err)
}
})
a.HandleMessage(func(s *adler.Session, msg []byte) {
if string(msg) == "leave_queue" {
if err := mm.RemoveFromQueue(s); err != nil {
log.Printf("enqueue matchmaking removal: %v", err)
}
}
})
// Use adler.WithProtocol(adler.Protobuf) to send queue events as protobuf frames.Both queue methods return ErrSessionNil for a nil session and ErrMatchmakerBusy
when the shared command buffer is full. A nil error means the command was accepted,
not that the worker has completed it. A busy error means the command was not accepted;
logging it reports the failure but does not complete cleanup. Applications requiring
guaranteed removal must arrange a later retry. Do not wait for buffer space from a
callback running on the matchmaker worker, since that worker drains the same buffer.
WithCommandBuffer(size) configures the capacity of pending add/remove commands
(default: 1024). This is separate from WithQueueLength(size), which limits players
in the main queue. The worker consumes commands and then updates the main/waiting queues:
AddToQueue / RemoveFromQueue -> commands (1024 pending commands) -> worker -> player queues
If the client sends a close frame, HandleClose receives the close status code and reason.
a.HandleClose(func(s *adler.Session, code int, reason string) {
log.Printf("close: code=%d reason=%q", code, reason)
})Use these options with adler.New(...):
WithWriteWait(time.Duration)sets the outbound write timeout, e.g.WithWriteWait(10*time.Second)(default: 10 seconds); non-positive values disable write deadlinesWithPongWait(time.Duration)sets the read timeout, e.g.WithPongWait(60*time.Second); each pong renews the deadline beforeHandlePongruns, and non-positive values disable the timeout (default: disabled)WithPingPeriod(time.Duration)sets the ping interval, e.g.WithPingPeriod(54*time.Second)(default: 60 seconds); non-positive values fall back to 54 secondsWithReadMaxPayloadSize(int)sets the inbound message limit in bytes (default: 64 KiB); non-positive values use the default, and oversized messages close the connection before reaching handlersWithMessageBufferSize(int)sets the outbound queue size; start with 64-256 and increase only if you hitErrBufferFullunder normal burstsWithDispatchAsync(bool)switches inbound dispatch to goroutine-per-message when enabledWithDeleteRoomOnEmpty(bool)controls automatic room deletion when the last session leaves (default:true)WithProtocol(adler.Protocol)selectsadler.JSON(default) oradler.Protobuffor protocol-aware messaging
When enabling PongWait, set PingPeriod below it. For example:
a := adler.New(
adler.WithWriteWait(10*time.Second),
adler.WithPongWait(60*time.Second),
adler.WithPingPeriod(54*time.Second),
adler.WithReadMaxPayloadSize(64*1024),
)The payload limit applies to complete messages, including fragmented messages.
Applications receiving larger messages can explicitly raise it, for example with
WithReadMaxPayloadSize(1024*1024) for 1 MiB. The limit is applied when the upgrader
is created; configure it through constructor options.
- Timeout options now use standard Go durations. Replace
WithWriteWait(10),WithPongWait(60), andWithPingPeriod(54)withWithWriteWait(10*time.Second),WithPongWait(60*time.Second), andWithPingPeriod(54*time.Second)respectively. Bare integers are interpreted as nanoseconds, not seconds. - The default inbound payload limit is now 64 KiB instead of the gws default of
16 MiB. Set
WithReadMaxPayloadSize(...)explicitly if larger messages are expected. RemoveFromQueuenow returns an error. Check it to detect rejected commands when the command buffer is full. Code using its previous function typefunc(*adler.Session)must be updated tofunc(*adler.Session) error.
See CHANGELOG.md for the complete release notes.
- Session storage is protected by an internal mutex.
- Server broadcast methods are safe for normal concurrent use.
UnsafeConn()bypasses Adler's internal coordination; only use it if you manage access carefully.
room := a.NewRoom("test")
a.HandleConnect(func(s *adler.Session) {
_ = room.Join(s)
})
room.HandleJoin(func(s *adler.Session) {
_ = s.WriteText([]byte("joined room"))
})This project is licensed under the MIT License. See LICENSE for the full text.