DEV Community

turboline-ai
turboline-ai

Posted on

Why Your Go WebSocket Client Keeps Dying in Production (And How to Fix It)

Most Go developers get a WebSocket market data stream working in about an hour. Keeping it alive through the weekend is the actual problem.

The gap between a demo that connects and prints ticks and a client that handles auth timeouts, silent disconnects, and malformed JSON without waking anyone up at 3am is wider than it looks. Here is what that gap actually contains.

The Connection Lifecycle Is Not One Thing

The most common mistake is treating the WebSocket connection as a single concern. It is not. There are at least four distinct responsibilities that need to run independently:

  • Authentication handshake
  • Tick deserialization
  • Heartbeat pings
  • Reconnect logic

When you collapse these into one goroutine, any failure in one area kills all the others. Go's goroutine model is specifically well-suited to separating these concerns cleanly. A sketch of what this looks like:

func runStream(ctx context.Context, cfg Config) {
    for {
        conn, err := dial(cfg)
        if err != nil {
            backoff(ctx)
            continue
        }

        if err := authenticate(conn, cfg.APIKey); err != nil {
            conn.Close()
            backoff(ctx)
            continue
        }

        done := make(chan struct{})
        go heartbeat(ctx, conn, done)
        go readTicks(ctx, conn, done)

        select {
        case <-done:
            conn.Close()
            backoff(ctx)
        case <-ctx.Done():
            conn.Close()
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The reconnect loop owns the lifecycle. Authentication happens before any goroutines are spawned. The heartbeat and tick reader share a done channel so either one can signal that the connection is unhealthy. This pattern means a dropped connection does not leak goroutines and does not require a full restart of your application.

Authentication Is a Blocking Step, Not a Background One

Providers like TraderMade and TickDB require you to send a token immediately after the connection opens. A common mistake is firing off the auth message and then immediately starting to read ticks. If the server rejects your credentials, the first message you receive will be an error, not a tick, and your tick parser will probably choke on it.

Treat authentication as synchronous. Send the auth payload, read exactly one response, check for success, then proceed. This keeps your tick parser clean because it only ever sees well-formed market data.

Tick Parsing Should Be Boring

If you are connecting to multiple asset classes (EUR/USD, BTC/USDT, XAU/USD), the good news is that most providers normalize to a consistent JSON shape:

{
  "symbol": "EUR/USD",
  "bid": 1.08412,
  "ask": 1.08415,
  "mid": 1.08413,
  "timestamp": 1718000000123
}
Enter fullscreen mode Exit fullscreen mode

This means your parsing layer does not need to know which feed it is consuming. One struct, one unmarshal call, one channel publish. Keep it that way. The moment you start branching on symbol type inside the parser you have created a maintenance problem that will grow with every new instrument you add.

Heartbeats Are Not Optional

Most WebSocket servers will silently drop connections that go quiet for 30 to 60 seconds. Your client needs to send periodic pings regardless of whether market data is flowing. This matters especially during off-hours when some instruments have no ticks.

A simple approach: send a ping every 20 seconds and treat any write error as a signal to tear down the connection and reconnect.

func heartbeat(ctx context.Context, conn *websocket.Conn, done chan struct{}) {
    ticker := time.NewTicker(20 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                close(done)
                return
            }
        case <-ctx.Done():
            return
        case <-done:
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Exponential Backoff Belongs in the Reconnect Loop

When a reconnect fails, do not hammer the server. Start at one second, double on each failure, cap somewhere around 60 seconds. Most transient network issues resolve within a few retries. If you are still failing after ten attempts, something more serious is wrong and you should log loudly rather than quietly retry forever.

The Concrete Takeaway

The difference between WebSocket code that works in a demo and code that survives a week of market hours is almost entirely operational: does auth block before reads start, do goroutines have clean exit paths, and does the reconnect loop back off intelligently. REST polling can not match the latency of a live tick stream, but a broken WebSocket stream is worse than polling. Get the lifecycle right first and the tick data takes care of itself.

Top comments (0)