Skip to content

Timeouts & Heartbeats

Proper timeout and heartbeat management is crucial for maintaining stable WebSocket connections to the Synthetix API.

Connection Lifecycle

  1. Initial Connection: TCP handshake and WebSocket upgrade
  2. Authentication: 30-second window for trade endpoint
  3. Active State: Send/receive messages with heartbeats
  4. Idle Timeout: Connection closed after inactivity
  5. Max Duration: Forced disconnect after 24 hours

Heartbeat Protocol

Client Heartbeat (Ping)

Send a ping message every 30 seconds to keep the connection alive:

{
  "type": "ping",
  "timestamp": 1704067200000
}

Server Response (Pong)

The server responds with:

{
  "type": "pong",
  "timestamp": 1704067200000
}

Implementation

class HeartbeatManager {
  constructor(ws) {
    this.ws = ws;
    this.pingInterval = 30000; // 30 seconds
    this.pongTimeout = 10000;  // 10 seconds
    this.intervalId = null;
    this.timeoutId = null;
    this.lastPong = Date.now();
  }
 
  start() {
    this.intervalId = setInterval(() => {
      this.sendPing();
    }, this.pingInterval);
 
    // Listen for pongs
    this.ws.addEventListener('message', (event) => {
      const message = JSON.parse(event.data);
      if (message.type === 'pong') {
        this.handlePong(message);
      }
    });
  }
 
  sendPing() {
    if (this.ws.readyState !== WebSocket.OPEN) {
      return;
    }
 
    const ping = {
      type: 'ping',
      timestamp: Date.now()
    };
 
    this.ws.send(JSON.stringify(ping));
 
    // Set pong timeout
    this.timeoutId = setTimeout(() => {
      console.error('Pong timeout - connection may be dead');
      this.ws.close(1001, 'Pong timeout');
    }, this.pongTimeout);
  }
 
  handlePong(message) {
    clearTimeout(this.timeoutId);
    this.lastPong = Date.now();
 
    // Calculate round-trip time
    const rtt = Date.now() - message.timestamp;
    console.log(`Heartbeat RTT: ${rtt}ms`);
  }
 
  stop() {
    clearInterval(this.intervalId);
    clearTimeout(this.timeoutId);
  }
 
  getLastPongAge() {
    return Date.now() - this.lastPong;
  }
}

Timeout Configuration

Connection Timeouts

Timeout TypeDurationDescription
Connection30 secondsMax time to establish WebSocket
Authentication30 secondsTime to authenticate after connect
Request30 secondsMax time for request response
Idle5 minutesConnection closed if no activity
Session24 hoursMaximum connection duration

Request Timeouts

Implement timeouts for all requests:

class RequestManager {
  constructor(ws) {
    this.ws = ws;
    this.requests = new Map();
    this.defaultTimeout = 30000; // 30 seconds
  }
 
  sendRequest(request, timeout = this.defaultTimeout) {
    return new Promise((resolve, reject) => {
      const requestId = request.requestId;
 
      // Store request handler
      this.requests.set(requestId, {
        resolve,
        reject,
        timestamp: Date.now()
      });
 
      // Set timeout
      const timeoutId = setTimeout(() => {
        const handler = this.requests.get(requestId);
        if (handler) {
          this.requests.delete(requestId);
          handler.reject(new Error(`Request timeout: ${requestId}`));
        }
      }, timeout);
 
      // Store timeout ID for cleanup
      this.requests.get(requestId).timeoutId = timeoutId;
 
      // Send request
      this.ws.send(JSON.stringify(request));
    });
  }
 
  handleResponse(response) {
    const handler = this.requests.get(response.requestId);
    if (handler) {
      clearTimeout(handler.timeoutId);
      this.requests.delete(response.requestId);
 
      if (response.error) {
        handler.reject(new Error(response.error.message));
      } else {
        handler.resolve(response.result);
      }
    }
  }
 
  cleanup() {
    // Clear all pending requests
    this.requests.forEach((handler, requestId) => {
      clearTimeout(handler.timeoutId);
      handler.reject(new Error('Connection closed'));
    });
    this.requests.clear();
  }
}

Connection Management

Complete Connection Manager

class WebSocketConnection {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.heartbeat = null;
    this.requestManager = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
    this.sessionStartTime = null;
    this.isAuthenticated = false;
  }
 
  async connect() {
    return new Promise((resolve, reject) => {
      // Connection timeout
      const connectTimeout = setTimeout(() => {
        reject(new Error('Connection timeout'));
        this.ws?.close();
      }, 30000);
 
      this.ws = new WebSocket(this.url);
      this.sessionStartTime = Date.now();
 
      this.ws.onopen = () => {
        clearTimeout(connectTimeout);
        console.log('WebSocket connected');
 
        // Initialize managers
        this.heartbeat = new HeartbeatManager(this.ws);
        this.requestManager = new RequestManager(this.ws);
 
        // Start heartbeat
        this.heartbeat.start();
 
        // Start session timer
        this.startSessionTimer();
 
        resolve();
      };
 
      this.ws.onclose = (event) => {
        this.handleClose(event);
      };
 
      this.ws.onerror = (error) => {
        clearTimeout(connectTimeout);
        this.handleError(error);
        reject(error);
      };
 
      this.ws.onmessage = (event) => {
        this.handleMessage(event);
      };
    });
  }
 
  handleMessage(event) {
    try {
      const message = JSON.parse(event.data);
 
      // Route to appropriate handler
      if (message.type === 'response') {
        this.requestManager.handleResponse(message);
      } else if (message.type === 'pong') {
        // Handled by heartbeat manager
      } else if (message.type === 'notification') {
        this.handleNotification(message);
      }
    } catch (error) {
      console.error('Message parse error:', error);
    }
  }
 
  handleClose(event) {
    console.log(`WebSocket closed: ${event.code} - ${event.reason}`);
 
    // Cleanup
    this.heartbeat?.stop();
    this.requestManager?.cleanup();
    this.isAuthenticated = false;
 
    // Determine if should reconnect
    if (event.code !== 1000 && this.shouldReconnect()) {
      this.scheduleReconnect();
    }
  }
 
  handleError(error) {
    console.error('WebSocket error:', error);
  }
 
  shouldReconnect() {
    // Don't reconnect if session expired
    if (this.isSessionExpired()) {
      console.log('Session expired - not reconnecting');
      return false;
    }
 
    // Check reconnect attempts
    return this.reconnectAttempts < this.maxReconnectAttempts;
  }
 
  scheduleReconnect() {
    const delay = Math.min(
      this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
      30000 // Max 30 seconds
    );
 
    this.reconnectAttempts++;
    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
 
    setTimeout(async () => {
      try {
        await this.connect();
        this.reconnectAttempts = 0; // Reset on success
 
        // Re-authenticate if needed
        if (this.url.includes('/trade')) {
          await this.authenticate();
        }
      } catch (error) {
        console.error('Reconnection failed:', error);
      }
    }, delay);
  }
 
  startSessionTimer() {
    // Force disconnect after 24 hours
    const sessionTimeout = 24 * 60 * 60 * 1000; // 24 hours
 
    setTimeout(() => {
      console.log('Session expired - closing connection');
      this.ws.close(1000, 'Session expired');
    }, sessionTimeout);
  }
 
  isSessionExpired() {
    if (!this.sessionStartTime) return false;
    const sessionDuration = Date.now() - this.sessionStartTime;
    return sessionDuration >= 24 * 60 * 60 * 1000;
  }
 
  async authenticate() {
    // Implement authentication with timeout
    const authTimeout = 30000; // 30 seconds
 
    try {
      const authRequest = {
        type: 'auth',
        requestId: 'auth-1',
        params: {
          // Auth params
        }
      };
 
      const result = await this.requestManager.sendRequest(authRequest, authTimeout);
 
      if (result.authenticated) {
        this.isAuthenticated = true;
        console.log('Authenticated successfully');
      } else {
        throw new Error('Authentication failed');
      }
    } catch (error) {
      console.error('Authentication error:', error);
      this.ws.close(1008, 'Authentication failed');
      throw error;
    }
  }
 
  close() {
    this.heartbeat?.stop();
    this.requestManager?.cleanup();
    this.ws?.close(1000, 'Normal closure');
  }
 
  getConnectionStats() {
    return {
      readyState: this.ws?.readyState,
      isAuthenticated: this.isAuthenticated,
      sessionAge: Date.now() - this.sessionStartTime,
      lastPongAge: this.heartbeat?.getLastPongAge(),
      reconnectAttempts: this.reconnectAttempts,
      pendingRequests: this.requestManager?.requests.size || 0
    };
  }
}

Monitoring Connection Health

class ConnectionMonitor {
  constructor(connection) {
    this.connection = connection;
    this.interval = null;
  }
 
  start() {
    this.interval = setInterval(() => {
      const stats = this.connection.getConnectionStats();
 
      // Check connection health
      if (stats.readyState !== WebSocket.OPEN) {
        console.warn('Connection not open:', stats.readyState);
      }
 
      // Check heartbeat
      if (stats.lastPongAge > 60000) {
        console.error('No pong received for 60s - connection may be dead');
      }
 
      // Check session age
      const hoursConnected = stats.sessionAge / (1000 * 60 * 60);
      if (hoursConnected > 23) {
        console.warn(`Session approaching 24h limit: ${hoursConnected.toFixed(1)}h`);
      }
 
      // Log stats
      console.log('Connection stats:', {
        ...stats,
        sessionAge: `${(stats.sessionAge / 1000).toFixed(0)}s`,
        lastPongAge: `${(stats.lastPongAge / 1000).toFixed(0)}s`
      });
    }, 30000); // Check every 30 seconds
  }
 
  stop() {
    clearInterval(this.interval);
  }
}

Implementation Notes

Heartbeat Implementation

Application-level heartbeats enable connection monitoring:

// Start heartbeat immediately after connection
ws.onopen = () => {
  heartbeat.start();
};

2. Handle All Timeout Scenarios

const timeouts = {
  connection: 30000,
  authentication: 30000,
  request: 30000,
  heartbeat: 30000,
  pong: 10000
};

3. Graceful Shutdown

async function shutdown(connection) {
  console.log('Shutting down gracefully...');
 
  // Cancel pending requests
  connection.requestManager.cleanup();
 
  // Stop heartbeat
  connection.heartbeat.stop();
 
  // Close connection
  connection.close();
 
  console.log('Shutdown complete');
}

4. Monitor Connection Quality

Track metrics to identify issues:

  • Round-trip time (RTT)
  • Missed pongs
  • Reconnection frequency
  • Request timeout rate

Common Issues

IssueCauseSolution
Frequent disconnectsNetwork instabilityImplement robust reconnection
Pong timeoutsHigh latencyIncrease pong timeout
Session expiry24-hour limitPlan for reconnection
Stuck requestsNo timeoutRequest timeout configuration required

Next Steps