A production-ready, scalable WebSocket package for Go with support for rooms, broadcasting, multi-node clustering, middleware, hooks, and extensibility.
Documentation · API Reference · Changelog
The room-based chat example — `go run ./examples/chat`
A raw gorilla/websocket (or nhooyr.io/websocket) connection gets you a
socket. Everything past that — the parts that turn "I can send a frame" into
"I can run this in production" — is what wshub provides:
| Capability | wshub | Raw WebSocket library |
|---|---|---|
| Connection registry, rooms, broadcasting | ✓ | You build it |
| Backpressure & drop policies | ✓ | You build it |
| Graceful drain + shutdown | ✓ | You build it |
| Multi-node scaling (Redis / NATS) | ✓ | You build it |
| Rate limiting (conns, rooms, messages) | ✓ | You build it |
| Metrics + official Prometheus subpackage | ✓ | You build it |
/healthz / /readyz probes |
✓ | You build it |
| Lifecycle hooks & middleware chain | ✓ | You build it |
wshub isn't a replacement for a WebSocket protocol library — it's built on
top of gorilla/websocket. It's the infrastructure layer around the socket
that most realtime services end up writing themselves, packaged once,
benchmarked, and kept production-safe by default.
SendToClient 105 ns/op 0 allocs (100K clients)
Broadcast 22.6 ms 0 allocs (100K clients)
Handshake rate 36,891 conn/s (10K connections)
Fanout ~499K msg/s (5K clients, single broadcaster)
SendToClient and Broadcast are in-process benchmarks: reproduce with
go test -bench=. -benchmem ./.... Handshake rate and fanout are end-to-end
load tests: reproduce with
make loadtest LOADTEST_ARGS="-scenario connect -clients 10000" and
make loadtest LOADTEST_ARGS="-scenario fanout -clients 5000" respectively —
full methodology and numbers in Benchmarks below.
- Production-Ready: Proper concurrency, graceful shutdown & drain, error handling
- Horizontally Scalable: Multi-node support via adapter pattern (Redis, NATS, or custom)
- Pluggable: Bring your own logger, metrics
- Middleware System: Chain handlers with custom logic
- Lifecycle Hooks: Hook into connection, message, room, and backpressure events
- Room Support: Group clients into rooms for targeted broadcasting
- Metrics & Logging: Built-in interfaces for observability; official Prometheus subpackage (
wshub/prometheus) - Configurable: Extensive configuration with builder pattern
- Limits & Rate Limiting: Control connections, rooms, and message rates
- Backpressure Control: Configurable drop policies with notification hooks
- Write Coalescing: Opt-in batching of text messages into single frames for reduced syscalls
- Health Probes: Built-in
/healthzand/readyzhandlers with JSON responses for Kubernetes - Global Counts: Cluster-wide client and room counts via presence gossip
- Zero Business Logic: Pure infrastructure, bring your own logic
go get github.com/KARTIKrocks/wshubpackage main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/KARTIKrocks/wshub"
)
func main() {
hub := wshub.NewHub(
wshub.WithMessageHandler(func(client *wshub.Client, msg *wshub.Message) error {
log.Printf("Message from %s: %s", client.ID, msg.Text())
return client.Send(msg.Data) // echo back
}),
)
go hub.Run()
http.HandleFunc("/ws", hub.HandleHTTP())
http.HandleFunc("/healthz", hub.HealthHandler())
http.HandleFunc("/readyz", hub.ReadyHandler())
srv := &http.Server{Addr: ":8080"}
go func() {
log.Println("Listening on :8080")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Two-phase shutdown for zero-downtime deploys
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
hub.Drain(ctx) // stop new connections, let existing ones finish
hub.Shutdown(ctx) // force-close anything remaining
srv.Shutdown(ctx)
}Note: since v1.7.0 the default origin check is
AllowSameOrigin. If your front-end is served from a different origin than the WebSocket endpoint, allowlist it withwshub.AllowOrigins(...)— see Configuration → Origin Checking.
Full guides live at kartikrocks.github.io/wshub:
| Guide | Covers |
|---|---|
| Getting Started | Install and run a minimal server |
| Hub | Broadcasting, client lookup, drain, health probes, shutdown |
| Client | Per-connection sending, metadata, callbacks |
| Messages | Message type, handlers, zero-alloc JSON fan-out |
| Rooms | Joining, room broadcasting, queries |
| Middleware | Built-in and custom middleware chains |
| Router | Event-based message dispatch |
| Hooks | Connection, message, and room lifecycle hooks |
| Adapters | Multi-node scaling via Redis or NATS |
| Presence | Cluster-wide client and room counts |
| Configuration | Buffers, timeouts, compression, origin checking |
| Limits | Connection, room, and rate limits |
| Metrics | Collector interface and the Prometheus subpackage |
| Errors | Sentinel errors and errors.Is matching |
Exact type signatures are generated from source on pkg.go.dev.
Runnable programs are in examples/ — simple, chat, auth,
notifications, metrics, and multinode.
Two kinds of numbers below:
- In-process dispatch (Go benchmarks with mock clients) — measures hub bookkeeping and channel push cost. Useful for spotting allocation regressions, not for predicting real throughput.
- End-to-end load tests (real
httptest.Server+gorilla/websocketdialer) — measures what an actual deployment will see.
Measured on an Intel i5-11400H @ 2.70GHz (12 cores), Go 1.27, Linux.
Run them yourself:
go test -bench=. -benchmem ./... # in-process micro-benchmarks
make loadtest LOADTEST_ARGS="..." # end-to-end load testsThese measure how fast the hub iterates its snapshot and pushes to client channels. They do not include TCP, writePump, or remote-client work.
| Operation | Clients | Time | Allocs |
|---|---|---|---|
Broadcast |
100,000 | 22.6 ms | 0 |
Broadcast |
1,000,000 | 269 ms | 0 |
BroadcastToRoom |
100,000 | 25.6 ms | 0 |
BroadcastToRoom |
1,000,000 | 293 ms | 0 |
BroadcastExcept |
100,000 | 23.4 ms | 1 |
BroadcastExcept |
1,000,000 | 274 ms | 1 |
BroadcastToRoomExcept |
100,000 | 23.0 ms | 1 |
BroadcastToRoomExcept |
1,000,000 | 269 ms | 1 |
| Operation | Scale | Time | Allocs |
|---|---|---|---|
SendToClient |
100,000 clients | 105 ns | 0 |
SendToClient |
1,000,000 clients | 112 ns | 0 |
SendToUser |
100,000 users | 167 ns | 1 |
SendToUser |
1,000,000 users | 166 ns | 1 |
| Operation | Nodes | Time | Allocs |
|---|---|---|---|
GlobalClientCount |
5 | 55.1 ns | 0 |
GlobalClientCount |
50 | 360 ns | 0 |
GlobalClientCount |
100 | 684 ns | 0 |
GlobalClientCount |
500 | 3.82 μs | 0 |
GlobalRoomCount |
5 | 111 ns | 0 |
GlobalRoomCount |
50 | 892 ns | 0 |
GlobalRoomCount |
100 | 1.54 μs | 0 |
GlobalRoomCount |
500 | 9.16 μs | 0 |
| Operation | Time | Allocs |
|---|---|---|
GetClient (1,000 clients) |
16.5 ns | 0 |
ClientCount |
0.25 ns | 0 |
GetClientByUserID |
45.8 ns | 0 |
RoomExists |
15.6 ns | 0 |
RoomCount |
15.0 ns | 0 |
GetMetadata |
16.7 ns | 0 |
SetMetadata |
28.0 ns | 0 |
| Operation | Time | Allocs |
|---|---|---|
Send (text) |
61.5 ns | 1 |
SendJSON |
501 ns | 5 |
| Mode | Time | Allocs |
|---|---|---|
| Built (cached) | 12.5 ns | 0 |
| Unbuilt (on-the-fly) | 12.4 ns | 0 |
End-to-end timings using real WebSocket connections via httptest.Server and
gorilla/websocket.Dialer. Latency is measured by embedding a unix-nano
timestamp in the payload and computing now - sent on receive. Reproduce with
make loadtest.
| Clients | Connect time | Rate | Mem/conn |
|---|---|---|---|
| 1,000 | 59 ms | 15,754 conn/s | 27.1 KB |
| 5,000 | 162 ms | 29,853 conn/s | 24.0 KB |
| 10,000 | 263 ms | 36,891 conn/s | 25.9 KB |
| Clients | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|
| 1,000 | 100,000 msg/s | 1.48 ms | 1.85 ms | 2.75 ms |
| 5,000 | 499,500 msg/s | 7.91 ms | 19.0 ms | 31.8 ms |
| 10,000 | 693,900 msg/s | 1.72 s | 3.19 s | 3.34 s |
Past ~5K clients on a single node, fanout latency grows steeply — the bottleneck is Go scheduler pressure across
3 × clientsgoroutines (readPump + writePump
- handshake server), not the hub's dispatch loop. For higher per-node fanout, tune
SendChannelSize, enableCoalesceWrites, or scale horizontally.
| Clients | Rooms | Per-room p50 | p99 |
|---|---|---|---|
| 5,000 | 100 | 5.68 ms | 7.88 ms |
| 10,000 | 100 | 12.29 ms | 19.01 ms |
| RTT/sec | p50 | p95 | p99 |
|---|---|---|---|
| 318,348 | 14.4 ms | 24.1 ms | 48.0 ms |
Note on
WithParallelBroadcast: in real load tests, parallel dispatch is consistently slower than the default serial path because the per-call cost oftrySend(RLock + defer/recover) dominates and parallel batching can't overcome it. The option remains for backward compatibility but is no longer recommended — use the default serial broadcast.
Always call
Build()on your middleware chain for best performance.
| Operation | Time | Allocs |
|---|---|---|
GetClient |
24.7 ns | 0 |
ClientCount |
0.17 ns | 0 |
Metadata (set+get) |
66.0 ns | 0 |
Broadcast (100 clients) |
4.4 μs | 121 |
| Operation | Time | Allocs |
|---|---|---|
NewMessage |
28.3 ns | 0 |
NewTextMessage |
28.2 ns | 0 |
NewBinaryMessage |
28.1 ns | 0 |
NewJSONMessage |
773 ns | 8 |
NewRawJSONMessage |
28.3 ns | 0 |
All Hub and Client methods are thread-safe. The package uses:
- RWMutex for client/room maps
- Separate mutexes for callbacks
- Channels for cross-goroutine communication
- WaitGroups for graceful shutdown
wshub sits on the network edge — it terminates untrusted WebSocket upgrades — so
the security surface is treated as part of the API, not an afterthought.
v1.7.0 changed DefaultConfig() from AllowAllOrigins to
AllowSameOrigin as a breaking change, because a default that accepts an upgrade
from any origin leaves every server built on it open to cross-site WebSocket
hijacking.
Every push and pull request to main is scanned by
CodeQL, with
a full re-scan weekly to catch newly published query patterns against unchanged
code. govulncheck gates every merge on advisories that are reachable from this
code's call graph. Both run separately against each of the four modules — the
root package, prometheus, adapter/redis, and adapter/nats — because a scan
started from the root stops at the nested go.mod boundaries and would miss the
adapters' own dependency trees. Dependabot tracks updates across all four, plus
the docs site and the GitHub Actions themselves.
See SECURITY.md for supported versions, what is in scope, and how to report a vulnerability privately.
Contributions welcome! Please read CONTRIBUTING.md for guidelines.