Introduction
When people bring up WebSocket, many think: "It's just a protocol for real-time communication — what's the big deal?"
Then the questions start:
- Why do connections drop after a minute?
- How do you tune timeouts between the client, the proxy, and the server?
- Which library should you pick — and why does it matter at all?
- How do you scale and balance stateful connections?
WebSocket is not just a protocol. It is a whole ecosystem where the choice of language and libraries, proxy configuration, and understanding how everything behaves under real load are critical.
This article is a practical experiment: how to build and test a WebSocket service on Go, Nginx, and Docker that can hold tens of thousands of simultaneous connections.
Why Go?
For testing a hypothesis about many WebSocket clients interacting, the choice of language is not just a preference — it is a critical factor for success.
1. Efficient concurrency management
Goroutines are Go's main weapon in the fight for resources:
- Lightweight: a single goroutine takes only a few kilobytes of memory
- Scalable: 50,000 active clients on one standard virtual machine is a reality, not a dream
Try doing the same with classic threads — you will run out of resources much earlier.
2. Network stack performance
Go was built for cloud services and high-load networks. The standard library gives you:
- Low latency when processing frames
- Direct access to system calls without extra abstractions
- High serialization/deserialization throughput
3. Connection lifecycle control
Go makes it easy to implement complex logic for real user behavior:
- Timeout management and clean connection shutdown
- Precise control over Keep-Alive and Ping/Pong requests
- Connection resilience checks through Nginx
Experiment architecture
Test environment
An isolated environment, built with Docker Compose:
$ docker-compose up -d --scale ws-server=3 --scale ws-client=10
What does this give?
- 3 WebSocket server instances (scaling)
- 10 load-generator clients
- Nginx as the proxy layer between them
Experiment objectives
- Pick a WebSocket implementation option at the Nginx level
- Tune timeouts (WebSocket Ping) for the Docker + Nginx stack
- Configure TLS/SSL (WSS)
- Implement connection monitoring at the Nginx level
- Configure load balancing
- Determine the maximum data size
- Add compression for text messages (permessage-deflate)
- Pick the optimal Go WebSocket library
Nginx: which configuration option to pick?
Options considered:
- njs (Nginx JavaScript). A modern approach with the ngx_http_js_module for asynchronous HTTP requests.
- OpenResty + lua-nginx-module. The standard for complex logic, with the lua-resty-websocket library.
- Nginx as a simple reverse proxy. ✅ Nginx passes the WebSocket through to the backend (Go), where the main logic lives.
- Nginx as an API Gateway + Keycloak. A smart filter with access checks via auth_request.
Chosen: option 3 (simple reverse proxy)
Why?
✅ Simple to maintain: declarative configuration is easy to read and review
✅ Fewer bugs: no programmatic code in the config — no runtime errors
✅ Minimal overhead: no virtual machines to initialize (Lua VM or V8)
✅ Predictable latency: native C modules are faster than interpreted code
✅ Security: fewer dependencies = smaller attack surface
✅ Fast updates: upstream Nginx receives security patches faster than forks
Choosing a Go WebSocket library
Comparison of the three leaders:
| Feature | gorilla/websocket | coder/websocket | gobwas/ws |
|---|---|---|---|
| API style | Traditional (callbacks) | Modern (Context) | Low-level (zero-copy) |
| Performance | Medium | High | Maximum |
| Context support | ❌ No | ✅ Yes (native) | ❌ No (needs wrapping) |
| Project status | Archived / stable | Actively maintained | Active |
| Learning curve | Low | Low | High |
| permessage-deflate | Partial | Full | Optional |
✅ Chosen: gorilla/websocket
Why?
For most tasks this library is enough. It is stable, easy to learn, and has a large community.
[!NOTE] For new projects, coder/websocket is recommended — a middle ground with a modern API and full Context support.
Step-by-step implementation
Step 1: Basic Nginx setup for WebSocket
For Nginx to understand WebSocket, the Upgrade and Connection headers must be passed explicitly:
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
...
location /ws-notifications/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
...
}
}
}
Step 2: Disable buffering
[!NOTE] The most important setting for WebSocket!
If buffering is enabled (proxy_buffering on), Nginx accumulates data in a buffer before sending it to the client, which adds huge delays.
location /ws-notifications/ {
...
# Buffering is off so messages go out instantly
proxy_buffering off;
...
}
Step 3: Configure timeouts
By default, Nginx closes the connection after 60 seconds of silence. For WebSocket, that is critical!
location /ws-notifications/ {
...
# Headroom in Nginx (90s) guarantees that if a packet is delayed
# by 5-10 seconds, the proxy won't cut the connection with "code 1006"
proxy_read_timeout 90s;
proxy_send_timeout 90s;
...
}
[!NOTE] Important: Nginx's proxy_read_timeout must be greater than your pongWait in Go, otherwise it will kill the connection earlier.
Step 4: Set up Ping/Pong on the server side (Go)
const (
// Time budget for sending a message
// Gives the network stack more time to flush buffers during temporary stalls.
writeWait = 15 * time.Second // 15 sec
// How long we wait for a response from the client
pongWait = 60 * time.Second // 60 sec
// Ping more often so Nginx doesn't close the socket
// The more frequent the ping, the faster you learn about a drop
pingPeriod = 30 * time.Second // 30 sec
)
Why does this matter?
✅ Connection liveness check
✅ Resets the Nginx timeout
✅ Fast detection of a dropped connection
Step 5: Always set deadlines
[!NOTE] Critically important!
Without a deadline, WriteMessage can hang forever (goroutine leak).
for {
select {
case message := <-echo:
ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := ws.WriteMessage(websocket.TextMessage,[]byte(message)); err != nil {
log.Println("EchoWriteMessageError:", err)
return
}
case <-pingTicker.C:
ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := ws.WriteMessage(websocket.PingMessage, nil); err != nil {
log.Println("PingWriteMessageError:", err)
return
}
}
}
Step 6: Set up WSS (WebSocket Secure)
For local development, use a self-signed certificate:
$ mkcert localhost 127.0.0.1 172.18.0.1
Nginx configuration:
server {
listen 443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/localhost.pem;
ssl_certificate_key /etc/nginx/localhost-key.pem;
}
Step 7: Connection monitoring
The stub_status module shows the number of active connections:
location /status {
stub_status;
}
Check:
$ curl -k https://localhost/status
Active connections: 1
Step 8: WebSocket balancing
[!NOTE] WebSocket is a stateful protocol. Dropping a connection = forcing the client to reconnect.
Solution: sticky sessions via ip_hash:
upstream go_sockets {
# Sticky sessions matter if the server keeps local client state
ip_hash;
# When Nginx looks up the host wsserver, Docker's DNS server
# returns a list of IP addresses for all running replicas of that service.
server ws-server:80;
}
Scaling with Docker Compose:
$ docker-compose up -d --scale ws-server=3
[!NOTE] DNS magic: Docker's internal DNS automatically balances requests to ws-server across all replicas.
Step 9: Message size limits
Rules of thumb:
- Text/JSON: no more than 1–5 MB
- Binary data: up to 10–50 MB
On the Go side:
const (
...
// limits the message size,
// to protect against DoS attacks (memory exhaustion)
readLimit = 65536 // 64 KB limit
...
)
func reader(ws *websocket.Conn, echo chan string) {
...
SetReadLimit(readLimit)
...
}
On the Nginx side:
location /ws-notifications {
...
# Maximum request body size (0 = unlimited).
# Helps when very large initial data is sent.
client_max_body_size 0;
# Buffer size for reading the request body.
# If the data is larger than 128k, Nginx writes it to a temporary file.
client_body_buffer_size 128k;
}
[!NOTE] If SetReadLimit(65536) is set in Go, the Nginx settings have no practical effect — Go will cut the client off first.
Step 10: Data compression (permessage-deflate)
Why? Text data (JSON, logs) compresses 5–10x.
Enabling it in Go:
var (
upgrader = websocket.Upgrader{
...
// Lets the server determine whether messages should be compressed
EnableCompression: true,
...
}
)
Compression levels:
| Level | Description |
|---|---|
| 1 (BestSpeed) | Lowest CPU load, but size shrinks little |
| 6 (Default) | Balance between speed and quality |
| 9 (BestCompression) | Maximum compression, heavy CPU load |
Recommendation:
✅ For JSON and text: level 1 or 2
✅ If you have many clients: set it to 1
✅ If saving traffic matters: set it to 6
Testing
Tools:
1. wscat (Node.js)
$ wscat -n -c wss://localhost/ws-notifications -P
2. websocat (for sending large files)
# Create a 100 KB test file
$ base64 /dev/urandom | tr -d '\n' | head -c 102400 > big_message.txt
# Send it
$ cat big_message.txt | websocat -v -k -B 200000 wss://localhost/ws-notifications
Conclusions
✅ Go is the ideal language for high-load WebSocket services
✅ Nginx as a simple reverse proxy is the most reliable option
✅ gorilla/websocket is enough for most tasks
✅ coder/websocket is the better choice for new projects
✅ Timeouts, deadlines, and limits are critical
✅ Data compression saves traffic but loads the CPU — use level 1–2
✅ Balancing via ip_hash is required for stateful connections