Contact Us

WebSocket in Go: From Experiment to a Production-Ready Solution

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.

Project repository

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

  1. Pick a WebSocket implementation option at the Nginx level
  2. Tune timeouts (WebSocket Ping) for the Docker + Nginx stack
  3. Configure TLS/SSL (WSS)
  4. Implement connection monitoring at the Nginx level
  5. Configure load balancing
  6. Determine the maximum data size
  7. Add compression for text messages (permessage-deflate)
  8. Pick the optimal Go WebSocket library

Nginx: which configuration option to pick?

Options considered:

  1. njs (Nginx JavaScript). A modern approach with the ngx_http_js_module for asynchronous HTTP requests.
  2. OpenResty + lua-nginx-module. The standard for complex logic, with the lua-resty-websocket library.
  3. Nginx as a simple reverse proxy. ✅ Nginx passes the WebSocket through to the backend (Go), where the main logic lives.
  4. 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