WebSocket Reconnection with Exponential Backoff and Jitter
Run a bot that takes live quotes over WebSocket for a few days and you learn one thing: the WebSocket will drop. Exchanges reap idle connections, networks wobble, servers get redeployed. What matters isn't whether it drops but what you do afterward. A badly written reconnect loop hammers the server hundreds of times a second while it's briefly down, making everything worse.
Detect the Drop First
The most common mistake is missing a silent death: the TCP connection is up but the other side sends nothing—no error, no data. Two mechanisms guard against it. A read deadline (no frame within the window means it's dead) and a periodic ping.
// Read deadline + pong handler: no frame within the window means the link is down
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// Fire pings periodically from a separate goroutine
go func() {
t := time.NewTicker(pingInterval)
defer t.Stop()
for range t.C {
if err := conn.WriteControl(websocket.PingMessage, nil,
time.Now().Add(5*time.Second)); err != nil {
return // can't even send a ping — the connection is already dead
}
}
}()
The crux is pingInterval < pongWait. A pong refreshes the deadline; no pong and the next ReadMessage throws a timeout error. That catches even a silent death within seconds.
Exponential Backoff — Stretch the Retry Interval
Once you've caught the drop, reconnect. But not immediately and infinitely. If every bot pounds a downed server without pause, the server never gets room to come back. The answer is exponential backoff: double the wait on each failure, with a ceiling.
func backoff(attempt int) time.Duration {
base := 500 * time.Millisecond
max := 30 * time.Second
// grow by 2^attempt (0.5s, 1s, 2s, 4s ... capped at 30s)
d := base * time.Duration(1<<uint(attempt))
if d > max {
d = max
}
return d
}
Jitter — Scatter the Reconnect Wave
Backoff alone isn't enough. With many bots attached to one server, they all drop at the same instant and retry on the same schedule, producing a thundering herd. So mix random jitter into the wait to spread reconnect times out.
func backoffWithJitter(attempt int) time.Duration {
d := backoff(attempt)
// add a random 0–50% of the computed interval to spread reconnects
jitter := time.Duration(rand.Int63n(int64(d) / 2))
return d + jitter
}
Always Restore Subscriptions After Reconnecting
This is the most frequently forgotten piece. Even on a successful reconnect, your previous subscriptions do not carry over to the new connection. The new socket is a blank slate. So keep the subscription list around and re-subscribe from scratch every time a connection opens.
func (c *Client) runForever(ctx context.Context) {
attempt := 0
for {
if ctx.Err() != nil {
return
}
conn, err := c.dial(ctx)
if err != nil {
wait := backoffWithJitter(attempt)
log.Printf("dial failed, retrying in %v (attempt=%d): %v", wait, attempt, err)
select {
case <-time.After(wait):
case <-ctx.Done():
return
}
attempt++
continue
}
attempt = 0 // connected, so reset the backoff
c.resubscribeAll(conn) // ★ re-subscribe every remembered channel
c.readLoop(ctx, conn) // blocks here; returns on drop and loops back up
}
}
The loop's flow is what matters. readLoop processes messages while the connection is alive and returns when it drops. The for then comes back around and retries the dial. The key detail is resetting attempt=0 on a successful connection. Miss that and every brief connect-then-drop leaves the wait pinned at 30 seconds.
Field Notes
- Think about a cap on infinite retries. Infinite retry is right for an infrastructure bot, but errors that retrying can't fix—an auth failure from a bad key—should be distinguished and stopped immediately.
- Treat a failed re-subscribe as a drop. If you send the subscribe message and no acknowledgement comes back, that connection can't be trusted; safer to tear it down and reconnect.
- Log your disconnects. How often do drops happen, do they cluster at particular hours—once the logs accumulate, patterns on the exchange's side become visible.
Before any flashy strategy comes a connection that quietly revives itself after a drop. Build one reconnect loop properly and the exchange can redeploy at 3 AM while your bot keeps running as if nothing happened.
This article is for educational and informational purposes and is not investment advice. Automated trading carries risk of principal loss, and you alone are responsible for the outcomes.
댓글
댓글 쓰기