Performance Tuning Guide

Copy Markdown View Source

Overview

This guide covers performance tuning for ZenWebsocket connections. Each parameter affects latency, throughput, memory usage, or reliability—understanding these tradeoffs helps optimize for your specific use case.

Configuration Parameters

Timeout Settings

ParameterDefaultDescription
timeout5000msConnection establishment timeout
request_timeout30000msTimeout for correlated request/response
heartbeat_config:disabledHeartbeat mode; no heartbeat is sent until you set it
heartbeat_interval30000msFallback interval used when heartbeat_config omits :interval

Heartbeats are off by default: heartbeat_interval alone starts no timer, so every snippet below sets heartbeat_config to enable one. See the README heartbeat section for the available :type values.

Tuning guidance:

# Low-latency trading (fast fail, quick detection)
{:ok, client} = Client.connect(url,
  timeout: 3000,           # Fail fast on connection issues
  request_timeout: 5000,   # Don't wait long for responses
  heartbeat_config: %{type: :ping_pong, interval: 10_000}  # Detect disconnects quickly
)

# High-latency networks (more tolerance)
{:ok, client} = Client.connect(url,
  timeout: 15_000,          # Allow for slow networks
  request_timeout: 60_000,  # Accommodate slow API responses
  heartbeat_config: %{type: :ping_pong, interval: 60_000}  # Reduce overhead
)

Reconnection Settings

ParameterDefaultDescription
retry_count3Maximum reconnection attempts
retry_delay1000msBase delay for exponential backoff
max_backoff30000msMaximum delay between attempts
reconnect_on_errortrueEnable automatic reconnection
restore_subscriptionstrueRestore subscriptions after reconnect

Exponential backoff formula:

delay = min(retry_delay * 2^attempt, max_backoff)

With retry_delay: 1000, max_backoff: 30_000

Attempt 0:  1000ms
Attempt 1:  2000ms
Attempt 2:  4000ms
Attempt 3:  8000ms
Attempt 4: 16000ms
Attempt 5: 30000ms  <- 32000 uncapped, clamped to max_backoff
Attempt 6: 30000ms  <- every later attempt stays at the cap

Tuning guidance:

# Production trading (aggressive reconnection)
{:ok, client} = Client.connect(url,
  retry_count: 10,         # Many attempts before giving up
  retry_delay: 500,        # Start with short delays
  max_backoff: 10_000,     # Cap at 10 seconds
  reconnect_on_error: true,
  restore_subscriptions: true
)

# Adapter-managed reconnection (disable internal)
{:ok, client} = Client.connect(url,
  reconnect_on_error: false  # Adapter handles all reconnection
)

Latency Monitoring

ParameterDefaultDescription
latency_buffer_size100Samples retained for percentile calculations

The LatencyStats module maintains a circular buffer of request latencies for p50/p99 calculations.

Structure: LatencyStats holds samples as small non-negative integers in an Erlang :queue, bounded by latency_buffer_size — insertion is O(1) with the oldest sample evicted at capacity, and percentile calculation sorts the bounded list. Memory is therefore proportional to latency_buffer_size and stops growing once the buffer is full. This repo publishes no measured per-sample figure; measure your own with Client.get_state_metrics/1 (see below) rather than budgeting from a guess.

# High-precision latency tracking
{:ok, client} = Client.connect(url,
  latency_buffer_size: 1000  # More samples for smoother percentiles
)

# Memory-constrained environment
{:ok, client} = Client.connect(url,
  latency_buffer_size: 25  # Minimal samples
)

Retrieving latency stats:

# Get summary with p50, p99, last sample, count
stats = Client.get_latency_stats(client)
# => %{p50: 45, p99: 120, last: 52, count: 100}

Rate Limiter Tuning

The RateLimiter module implements a token bucket algorithm supporting different exchange patterns.

Configuration Options

config = %{
  tokens: 100,              # Bucket capacity
  refill_rate: 10,          # Tokens added per interval
  refill_interval: 1000,    # Interval in milliseconds
  request_cost: &MyModule.cost_function/1
}

{:ok, limiter} = RateLimiter.init(:my_limiter, config)

Exchange-Specific Cost Functions

Different exchanges use different rate limit models:

The bucket sizes below are illustrative — they are scaled to the built-in cost functions' own relative units, not to any exchange's published credit values. Size your bucket against the venue's current published limits.

# Deribit: credit-based (methods have different costs)
config = %{
  tokens: 10_000,           # illustrative capacity in deribit_cost/1 units
  refill_rate: 1000,        # illustrative refill per interval
  refill_interval: 1000,
  request_cost: &RateLimiter.deribit_cost/1
}

# Built-in cost function — a relative scale, NOT Deribit's published credits:
# - public/* methods: 1
# - private/get_* methods: 5
# - private/set_* methods: 10
# - private/buy, private/sell: 15
# - anything else: 5

# Binance: weight-based
config = %{
  tokens: 1200,             # illustrative capacity in binance_cost/1 units
  refill_rate: 20,
  refill_interval: 1000,
  request_cost: &RateLimiter.binance_cost/1
}

# Simple fixed-rate (Coinbase, etc.)
config = %{
  tokens: 10,               # illustrative: 10 requests
  refill_rate: 10,
  refill_interval: 1000,    # per second
  request_cost: &RateLimiter.simple_cost/1
}

Deribit's published limits, from https://docs.deribit.com/articles/rate-limits.md (retrieved 2026-08-22), for mapping the illustrative numbers onto reality:

  • Non-matching-engine requests: 500 credits per request against a 50,000-credit maximum pool, refilled at 10,000 credits/second — "Credits are refilled at a rate that allows up to 20 requests per second (10,000 credits per second)", with burst up to 100 requests.
  • Matching-engine requests are tier-based on 7-day volume, recalculated hourly: Tier 1 (>$25M) 30 req/s sustained / 100 burst, down to Tier 4 (up to $1M) 5 req/s / 20 burst.
  • private/move_positions is special-cased at 100,000 credits per request against a 600,000-credit pool.
  • Exhausting credits returns too_many_requests (code 10028) and terminates the session.

Refill Timers Are Delivered to the Calling Process

RateLimiter.init/2 schedules refills with Process.send_after/3 addressed to whichever process called init/2. That process must handle {:refill, name} and call RateLimiter.refill/1, which reschedules the next timer. Miss the clause and refills stop permanently after the first tick — the bucket drains once and never recovers.

defmodule MyApp.Trader do
  use GenServer

  alias ZenWebsocket.RateLimiter

  @impl true
  def init(_opts) do
    {:ok, :my_limiter} =
      RateLimiter.init(:my_limiter, %{
        tokens: 100,
        refill_rate: 10,
        refill_interval: 1000,
        request_cost: &RateLimiter.simple_cost/1
      })

    {:ok, %{}}
  end

  # REQUIRED — without this clause refills stop after the first timer fires.
  @impl true
  def handle_info({:refill, name}, state) do
    RateLimiter.refill(name)
    {:noreply, state}
  end

  @impl true
  def terminate(_reason, _state) do
    # ETS tables are not cleaned up on process exit.
    RateLimiter.shutdown(:my_limiter)
  end
end

The limiter is caller-driven. ZenWebsocket.Client never consults RateLimiter — nothing in the send path checks a bucket. You call RateLimiter.consume/2 yourself before Client.send_message/2, or requests go out unthrottled.

Allow/Deny and Retry

The rate limiter is an allow/deny gate. Rate-limited requests are not queued or retried inside the limiter — the caller decides when to retry.

case RateLimiter.consume(:my_limiter, request) do
  :ok -> send_request(request)
  {:error, :rate_limited} -> schedule_retry(request)
end

{:ok, status} = RateLimiter.status(:my_limiter)
# => %{tokens: 50, queue_size: 0, pressure_level: :none, suggested_delay_ms: 0}
# queue_size / pressure_level / suggested_delay_ms stay at those defaults
# for response compatibility; they are not a live backpressure signal.

Telemetry Events

ZenWebsocket emits telemetry events for monitoring. Attach handlers for observability.

Available Events

EventMeasurementsMetadata
[:zen_websocket, :connection, :upgrade]connect_time_msurl
[:zen_websocket, :heartbeat, :pong]rtt_mstype
[:zen_websocket, :rate_limiter, :consume]tokens_remaining, costname
[:zen_websocket, :rate_limiter, :refill]tokens_before, tokens_after, refill_ratename
[:zen_websocket, :request_correlator, :track]countid, timeout_ms
[:zen_websocket, :request_correlator, :resolve]count, round_trip_msid
[:zen_websocket, :request_correlator, :timeout]countid
[:zen_websocket, :request_correlator, :fail_all]countid, reason
[:zen_websocket, :subscription_manager, :add]countchannel
[:zen_websocket, :subscription_manager, :remove]countchannel
[:zen_websocket, :subscription_manager, :restore]channel_countchannels
[:zen_websocket, :pool, :route]health, pool_sizeselected
[:zen_websocket, :pool, :health]pool_size, avg_health
[:zen_websocket, :pool, :failover]attemptfailed_pid, reason

Setting Up Telemetry Handlers

Important: Call setup/0 in your application's start/2 callback to attach handlers before any connections are made:

# lib/my_app/application.ex
def start(_type, _args) do
  MyApp.TelemetryHandler.setup()

  children = [
    # ... your supervision tree
  ]

  Supervisor.start_link(children, strategy: :one_for_one)
end
defmodule MyApp.TelemetryHandler do
  require Logger

  def setup do
    events = [
      [:zen_websocket, :rate_limiter, :consume],
      [:zen_websocket, :request_correlator, :resolve],
      [:zen_websocket, :request_correlator, :timeout]
    ]

    :telemetry.attach_many(
      "my-app-zen-websocket",
      events,
      &__MODULE__.handle_event/4,
      nil
    )
  end

  def handle_event([:zen_websocket, :rate_limiter, :consume], measurements, metadata, _config) do
    Logger.debug("Rate limiter #{metadata.name}: #{measurements.tokens_remaining} tokens remaining")
  end

  def handle_event([:zen_websocket, :request_correlator, :resolve], measurements, metadata, _config) do
    if measurements.round_trip_ms > 1000 do
      Logger.warning("Slow request: #{inspect(metadata.id)} took #{measurements.round_trip_ms}ms")
    end
  end

  def handle_event([:zen_websocket, :request_correlator, :timeout], _measurements, metadata, _config) do
    Logger.error("Request timeout: #{inspect(metadata.id)}")
  end
end

Memory Characteristics

Measure, Don't Estimate

This repo ships no memory benchmark, so it publishes no per-connection byte figures. Measure the connections you actually run:

metrics = Client.get_state_metrics(client)
# %{
#   connection_state: :connected,
#   active_heartbeats_size: 1,
#   subscriptions_size: 12,
#   pending_requests_size: 5,
#   state_memory: 1024,        # :erts_debug.size(term) of the state term, in WORDS
#   heartbeat_failures: 0,
#   last_heartbeat_at: 123_456,
#   heartbeat_timer_active: true,
#   message_queue_len: 0,
#   memory: 21_000,            # :erlang.process_info(:memory), in bytes
#   reductions: 98_765
# }

state_memory is :erts_debug.size(term) — a word count for the state term (multiply by :erlang.system_info(:wordsize) for bytes), while memory is the GenServer process's own byte total from Process.info/2. The Gun connection is a separate process; size it with Process.info(client.gun_pid, :memory).

What Grows With What

ComponentGrows with
RequestCorrelatornumber of in-flight correlated requests (bounded by how many you issue before request_timeout)
SubscriptionManagernumber of tracked subscription channels
LatencyStatsnumber of retained samples, hard-capped at latency_buffer_size
RateLimiter ETS tablefixed: one config map (~200 bytes) plus one token counter (8 bytes), per ZenWebsocket.RateLimiter's "Memory Characteristics" — and not freed on process exit; call RateLimiter.shutdown/1

Memory Optimization

# Memory-constrained configuration
{:ok, client} = Client.connect(url,
  latency_buffer_size: 25,    # Smaller latency buffer
  request_timeout: 10_000     # Shorter timeout = fewer pending requests
)

# Initialize a smaller rate-limit bucket
RateLimiter.init(:my_limiter, %{
  tokens: 25,
  refill_rate: 10,
  refill_interval: 1000,
  request_cost: &RateLimiter.simple_cost/1
})

Common Tuning Scenarios

High-Frequency Trading

Optimize for lowest latency, fast failure detection:

{:ok, client} = Client.connect(url,
  timeout: 2000,
  request_timeout: 3000,
  heartbeat_config: %{type: :ping_pong, interval: 5000},
  retry_count: 3,
  retry_delay: 100,
  max_backoff: 1000,
  latency_buffer_size: 500
)

Market Data Collection

Optimize for reliability, handle reconnection gracefully:

{:ok, client} = Client.connect(url,
  timeout: 10_000,
  request_timeout: 30_000,
  heartbeat_config: %{type: :ping_pong, interval: 30_000},
  retry_count: 20,
  retry_delay: 1000,
  max_backoff: 60_000,
  restore_subscriptions: true
)

Resource-Constrained Environment

Minimize memory and CPU overhead:

{:ok, client} = Client.connect(url,
  heartbeat_config: %{type: :ping_pong, interval: 60_000},  # Less frequent heartbeats
  latency_buffer_size: 10,                                  # Minimal latency tracking
  retry_count: 3                                            # Limited retries
)

Debugging Performance Issues

Enable Debug Logging

{:ok, client} = Client.connect(url, debug: true)

This logs detailed connection lifecycle events including Gun operations, WebSocket upgrades, and message timing.

Check Connection State

# Connection state (atom)
state = Client.get_state(client)
# => :connected | :connecting | :disconnected

# Latency metrics
stats = Client.get_latency_stats(client)
# => %{p50: 45, p99: 120, last: 52, count: 100}

# Heartbeat health - five keys
health = Client.get_heartbeat_health(client)
# => %{
#      active_heartbeats: [],      # list of heartbeat types that have been acknowledged
#      last_heartbeat_at: nil,
#      failure_count: 0,
#      config: :disabled,
#      timer_active: false
#    }
#
# last_heartbeat_at is System.monotonic_time(:millisecond) once a heartbeat has
# been acknowledged - a large negative integer on a freshly booted node, never a
# wall-clock DateTime. It stays nil until then, which is always the case with
# config: :disabled.

# Connection metrics
metrics = Client.get_state_metrics(client)
# => %{subscriptions_size: 12, pending_requests_size: 5, state_memory: 1024, ...}

Monitor Rate Limiter

{:ok, status} = RateLimiter.status(:my_limiter)
IO.inspect(status)
# => %{tokens: 85, queue_size: 0, pressure_level: :none, suggested_delay_ms: 0}

Summary

GoalKey Parameters
Lower latencyReduce timeout, request_timeout, and the :interval in heartbeat_config
Higher reliabilityIncrease retry_count, max_backoff
Less memoryReduce latency_buffer_size
Better observabilityAttach telemetry handlers, enable debug: true
Prevent rate limitsConfigure an appropriate request_cost function, monitor remaining tokens