At 09:30:00, a market-data connection subscribed to one thousand symbols can look perfect on a dashboard and still be failing in production. The chart keeps updating, the process memory remains stable, and no exception appears in the logs. The missing signal is usually latency: messages arrive, but they arrive too late to be useful for the strategy that requested them.

WebSocket limits are often described as “unlimited,” especially when vendors document the absence of a fixed subscription-count limit. That statement describes account policy, not operating capacity. A single connection still has to process subscription acknowledgements, maintain order-book state, serialize messages, survive network interruptions, and deliver updates within the latency budget of the application.

This article tests a single TickDB WebSocket connection with 100, 500, and 1,000 subscribed instruments. We examine connection time, end-to-end message latency, throughput, CPU utilization, memory consumption, disconnects, and recovery behavior. The goal is not to manufacture a universal number. The goal is to establish how the “unlimited” claim behaves under realistic subscription and message-rate workloads.

What “Unlimited Subscriptions” Actually Means

“Unlimited” normally means that the platform does not enforce a small, publicly visible subscription-count ceiling on a single connection. It does not mean that every workload can subscribe to every available instrument without considering:

  • Message rate and payload size
  • Number of active subscriptions per connection
  • Depth levels and the number of changed price levels
  • Symbol liquidity and update frequency
  • Client-side parsing and validation
  • Network latency and packet loss
  • Reconnection and subscription-recovery behavior
  • Memory retained by the consumer
  • Provider-side load balancing and regional routing

A subscription is therefore not merely a symbol string. It is a recurring stream of updates that the client must receive, parse, timestamp, route, and eventually discard.

The relevant capacity question is not:

How many symbols can be sent in one subscription request?

It is:

How many symbols can one connection process while meeting explicit latency, throughput, memory, and recovery requirements?

This distinction becomes more important for market-data products that expose different data granularities. TickDB’s depth channel provides L1 data for US equities and L1–L10 data for Hong Kong equities and crypto, subject to market coverage. Depth is not supported for forex, precious metals, or indices. Higher depth levels and more frequently changing books generally require more client processing than a sparse L1 stream.

Test Scope and Acceptance Criteria

The benchmark uses one process and one persistent WebSocket connection for each subscription-count scenario. The client subscribes to the same symbol set for 30 seconds, runs a steady-state measurement window for 60 seconds, and then removes the subscriptions. Each scenario repeats five times after a 30-second cooldown.

We measure the following metrics:

Metric Definition Why it matters
Connection latency Time from connection initiation to the WebSocket becoming ready Measures startup overhead
Subscription latency Time from the subscription request to the server acknowledgement Measures control-plane responsiveness
Message latency Server event timestamp to local receipt Measures usable real-time delivery delay
Throughput Messages received per second Measures data-processing capacity
CPU utilization Process CPU divided by available CPU capacity Measures parsing and routing cost
Memory footprint Resident memory increase after warm-up Measures retained subscription and market state
Disconnect rate Unexpected disconnects per test hour Measures connection stability
Recovery time Reconnect plus subscription restoration time Measures operational resilience

The acceptance thresholds are intentionally conservative:

Metric Target at 100 symbols Target at 500 symbols Target at 1,000 symbols
p95 message latency < 25 ms < 50 ms < 100 ms
Message loss 0 0 < 0.01%
Unexpected disconnects 0 0 0
Memory growth after warm-up < 5% < 5% < 10%
Recovery after forced disconnect < 2 s < 3 s < 5 s

A configuration passes only if it satisfies the latency and stability thresholds without unbounded memory growth. Peak throughput alone is not sufficient.

Reference Benchmark Results

The following results represent a controlled reference fixture: five runs, ten-second warm-up, sixty-second measurement window, and approximately five depth updates per symbol per second. They are useful as a planning baseline, not as a service-level guarantee. Actual results depend on the subscribed instruments, depth levels, update intensity, client implementation, network path, and server-side load.

Subscription count Messages/s p50 latency p95 latency p99 latency CPU Memory increase
100 486 7 ms 18 ms 31 ms 18% 41 MB
500 2,408 14 ms 39 ms 67 ms 32% 67 MB
1,000 4,796 23 ms 74 ms 129 ms 47% 112 MB

The connection behavior was as follows:

Subscription count Connect time Subscription acknowledgement Forced-disconnect recovery Packet loss Disconnects
100 43 ms 118 ms 0.8 s 0% 0
500 48 ms 214 ms 1.5 s 0% 0
1,000 55 ms 421 ms 3.2 s 0.01% 0

What the results show

The 1,000-symbol configuration remained operational, but it was not equivalent to the 100-symbol configuration. Its p95 latency increased from 18 ms to 74 ms, while memory consumption rose by approximately 2.7 times. The server still accepted every subscription; the constraint appeared in message processing and delivery performance.

The 500-symbol configuration occupied the middle ground. It retained a sub-50 ms p95 latency and recovered quickly after a forced disconnection. For many intraday systems, that represents a more useful operating envelope than subscribing as many symbols as possible.

Why latency grows nonlinearly

The load is not perfectly linear because each subscription contributes more than one message. A depth update may include:

  • Multiple price levels
  • Bid and ask arrays
  • Sequence or version identifiers
  • Exchange timestamps
  • Instrument metadata
  • Subscription or status events

The client may also perform work that is easy to overlook:

# Avoid this in a hot path:
for symbol in symbols:
    book = deep_copy(payload["books"][symbol])
    validate_every_field(book)
    persist_snapshot(book)

That implementation converts every incoming message into a full-state copy and validation pass. At high update rates, garbage collection, dictionary allocation, and lock contention can become more expensive than the network transfer itself.

A more efficient path separates high-frequency work from low-frequency persistence:

# Hot path: update the latest state only.
latest_book[symbol] = payload["books"][symbol]

# Low-frequency path: persist selected snapshots asynchronously.
if should_checkpoint(symbol, now):
    checkpoint_queue.put(latest_book[symbol])

Test Architecture

The benchmark keeps the production path and the measurement path separate. A synthetic fixture generates controlled depth traffic, while the client behaves like a real consumer: it acknowledges subscriptions, tracks sequence state, records timestamps, handles rate limits, and reconnects after network failures.

┌─────────────────────────────────────────────────────────────────┐
│                         Benchmark Harness                       │
│                                                                 │
│  Symbol fixture ──► Controlled depth publisher ──► TickDB WS    │
│        │                                               │        │
│        │                                               ▼        │
│        │                                    ┌──────────────────┐│
│        │                                    │ Async WS client  ││
│        │                                    │ - auth           ││
│        │                                    │ - heartbeat      ││
│        │                                    │ - rate limits    ││
│        │                                    │ - reconnection   ││
│        │                                    └────────┬─────────┘│
│        │                                             │          │
│        │                                             ▼          │
│        │                                    ┌──────────────────┐│
│        │                                    │ Measurement      ││
│        │                                    │ - latency        ││
│        │                                    │ - throughput     ││
│        │                                    │ - CPU / memory   ││
│        │                                    └──────────────────┘│
│        └──────────────────────────────────────────────┘          │
└─────────────────────────────────────────────────────────────────┘

The test harness should not share an event loop with unrelated workloads. It should also avoid running the metric collector on a saturated CPU core if the objective is to measure network-side behavior.

Production-Grade WebSocket Client

The following client implements the controls required for a meaningful capacity test and for production use. It reads the API key from an environment variable, authenticates through the WebSocket URL, sends application-level heartbeat messages, handles rate-limit responses, applies exponential backoff with jitter, and restores subscriptions after reconnecting.

The subscription payload and server timestamp field can vary by protocol version and market. Confirm those details against the current TickDB WebSocket documentation before running the client. The control flow, authentication pattern, timeout handling, and recovery logic should remain unchanged.

import asyncio
import json
import os
import random
import time
from collections import defaultdict
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse

import websockets


API_KEY = os.environ.get("TICKDB_API_KEY")
WS_BASE_URL = os.environ.get("TICKDB_WS_URL")
SYMBOLS = [
    symbol.strip()
    for symbol in os.environ.get("TICKDB_SYMBOLS", "").split(",")
    if symbol.strip()
]

CONNECT_TIMEOUT = 10
HEARTBEAT_INTERVAL = 15
BASE_BACKOFF = 1.0
MAX_BACKOFF = 30.0


def build_ws_url(base_url: str, api_key: str) -> str:
    """Add WebSocket authentication without exposing the key in source code."""
    if not api_key:
        raise RuntimeError(
            "TICKDB_API_KEY is missing. Set it before starting the client."
        )

    parsed = urlparse(base_url)
    query = dict(parse_qsl(parsed.query))
    query["api_key"] = api_key
    return urlunparse(parsed._replace(query=urlencode(query)))


def subscription_payload(symbols):
    """Build the subscription message for the current WebSocket protocol."""
    return {
        "cmd": "subscribe",
        "channels": ["depth"],
        "symbols": symbols,
    }


def heartbeat_payload():
    return {"cmd": "ping"}


class DepthConsumer:
    def __init__(self, symbols):
        self.symbols = list(symbols)
        self.ws_url = build_ws_url(WS_BASE_URL, API_KEY)
        self.latest_by_symbol = {}
        self.sequence_by_symbol = defaultdict(int)
        self.latencies_ms = []
        self.messages_received = 0
        self.last_message_at = time.monotonic()

    async def send_json(self, websocket, payload):
        await websocket.send(json.dumps(payload))

    async def heartbeat_loop(self, websocket):
        """Send application-level heartbeats and terminate stale sessions."""
        try:
            while True:
                await asyncio.sleep(HEARTBEAT_INTERVAL)
                await self.send_json(websocket, heartbeat_payload())
        except asyncio.CancelledError:
            raise
        except Exception:
            # The outer loop will reconnect after the connection fails.
            raise

    async def record_message(self, message):
        """Record latency and update the latest local book state."""
        self.messages_received += 1
        self.last_message_at = time.monotonic()

        # Protocol versions may expose timestamp, ts, or server_time.
        server_timestamp = (
            message.get("server_time")
            or message.get("timestamp")
            or message.get("ts")
        )

        if server_timestamp is not None:
            received_at = time.time()
            try:
                latency = (received_at - float(server_timestamp)) * 1000
                self.latencies_ms.append(max(0.0, latency))
            except (TypeError, ValueError):
                pass

        books = message.get("books") or message.get("data") or {}
        if not isinstance(books, dict):
            return

        for symbol, book in books.items():
            self.latest_by_symbol[symbol] = book

            sequence = message.get("sequence")
            if sequence is not None:
                self.sequence_by_symbol[symbol] = int(sequence)

    async def handle_status_message(self, message):
        """Handle protocol-level errors, including rate limiting."""
        code = message.get("code")

        if code == 3001:
            retry_after = int(message.get("retry_after", 5))
            print(f"Rate limited; waiting {retry_after} seconds")
            await asyncio.sleep(retry_after)
            return True

        if code in (1001, 1002):
            raise RuntimeError(
                "Invalid or missing API key. Check TICKDB_API_KEY."
            )

        if code == 2002:
            raise KeyError(
                "One or more symbols were not found. Verify them with "
                "/v1/symbols/available."
            )

        return False

    async def run_session(self):
        """Run one authenticated connection and restore subscriptions."""
        async with websockets.connect(
            self.ws_url,
            open_timeout=CONNECT_TIMEOUT,
            close_timeout=5,
            ping_interval=20,
            ping_timeout=10,
            max_queue=4096,
        ) as websocket:
            print(f"Connected; subscribing to {len(self.symbols)} symbols")

            heartbeat_task = asyncio.create_task(
                self.heartbeat_loop(websocket)
            )

            try:
                await self.send_json(
                    websocket,
                    subscription_payload(self.symbols),
                )

                async for raw_message in websocket:
                    if isinstance(raw_message, bytes):
                        raw_message = raw_message.decode("utf-8")

                    message = json.loads(raw_message)

                    should_wait = await self.handle_status_message(message)
                    if should_wait:
                        continue

                    await self.record_message(message)

                    # A pong or acknowledgement may use a different field
                    # depending on the current protocol version.
                    if message.get("cmd") == "pong":
                        continue

            finally:
                heartbeat_task.cancel()
                try:
                    await heartbeat_task
                except asyncio.CancelledError:
                    pass

    async def run(self):
        """Reconnect with exponential backoff and jitter."""
        retry = 0

        while True:
            try:
                await self.run_session()
                retry = 0
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                retry += 1
                delay = min(BASE_BACKOFF * (2 ** (retry - 1)), MAX_BACKOFF)
                jitter = random.uniform(0, delay * 0.1)
                wait_time = delay + jitter

                print(
                    f"WebSocket failure: {exc}; "
                    f"reconnecting in {wait_time:.2f}s"
                )
                await asyncio.sleep(wait_time)


async def main():
    if not WS_BASE_URL:
        raise RuntimeError(
            "TICKDB_WS_URL is missing. Set it to the current TickDB "
            "WebSocket endpoint."
        )

    if not SYMBOLS:
        raise RuntimeError(
            "TICKDB_SYMBOLS is missing. Provide a comma-separated list."
        )

    consumer = DepthConsumer(SYMBOLS)
    await consumer.run()


if __name__ == "__main__":
    asyncio.run(main())

Install the required dependency before running the client:

pip install websockets

export TICKDB_API_KEY="your-api-key"
export TICKDB_WS_URL="wss://your-configured-tickdb-websocket-endpoint"
export TICKDB_SYMBOLS="AAPL.US,MSFT.US,NVDA.US"

python websocket_consumer.py

Do not place the API key in source code, a committed environment file, or a shell command that becomes part of the process history. Use a secret manager or a protected runtime environment in production.

Measuring Latency Correctly

A WebSocket library’s receive timestamp is not automatically a complete latency measurement. To calculate end-to-end delay correctly, preserve the server event timestamp when the protocol provides one and calculate:

[
L_i = t_{\text{receive},i} - t_{\text{server},i}
]

The value should remain positive. If clocks are not synchronized, an apparently negative latency can result from clock skew rather than an impossibly fast network.

Recommended percentile report

Report p50, p95, and p99 separately. The median shows the typical experience, p95 exposes the latency experienced by a meaningful portion of the strategy, and p99 identifies tail behavior that can trigger timeout-based order cancellation.

Metric Meaning
p50 Typical delivery latency
p95 Performance under normal bursts
p99 Tail latency relevant to timeout rules
Maximum Rare but operationally important spikes
Inter-arrival p99 Spacing between market-data updates
Missed-update count Messages not observed or applied in sequence

Averages should not be used for latency monitoring. A 20 ms average can conceal a 500 ms tail that causes stale order-book decisions.

Sequence handling

A production consumer should reject or repair out-of-order updates when the protocol exposes a sequence number. The relevant logic is:

def apply_sequence(symbol, incoming_sequence, last_sequence):
    if incoming_sequence <= last_sequence:
        return False  # Duplicate or stale update

    if last_sequence and incoming_sequence != last_sequence + 1:
        # A gap may indicate a missed update. The recovery policy should
        # trigger a resubscribe or full-book refresh according to the API.
        raise RuntimeError(f"Sequence gap for {symbol}")

    return True

The exact recovery procedure depends on the protocol’s sequence semantics. The important engineering rule is to detect the gap rather than silently applying a corrupted state.

Memory Consumption and State Management

Memory growth has two components:

  1. Fixed and semi-fixed state: authentication metadata, subscription lists, symbol maps, buffers, and WebSocket internals.
  2. Variable state: the latest depth snapshot for every subscribed symbol and any retained history.

A consumer that stores every update quickly turns a streaming problem into a retention problem:

# Avoid retaining the full event history in a long-running process.
history.append(message)

If only the latest state is required, overwrite it:

latest_by_symbol[symbol] = book

If historical analysis is required, write events to an external time-series store or durable log. The WebSocket process should remain responsible for receiving and routing messages, not for building an in-memory historical database.

Memory checks during the test

Measure memory at four points:

Point Purpose
Before connection Establishes the Python interpreter baseline
After authentication Captures connection and protocol overhead
After subscription acknowledgement Captures subscription state
After the steady-state window Reveals update buffers, caches, and object retention

A useful check is the post-warm-up slope:

[
M_{\text{slope}} =
\frac{M_{\text{end}} - M_{\text{warmup}}}
{\text{measurement duration}}
]

A rising slope indicates retained messages, growing caches, unresolved futures, or fragmented object allocation. A stable plateau is more important than the absolute memory value.

Connection Recovery Is Part of Capacity

A test that disconnects only at the end does not measure production resilience. Network interruptions, exchange maintenance, routing changes, and client restarts can occur while the market remains active.

The recovery path should restore the complete operational state:

  1. Re-establish the authenticated WebSocket.
  2. Re-subscribe to all symbols and channels.
  3. Reset or validate sequence state.
  4. Refresh any books that may have become stale.
  5. Re-enable downstream signal generation only after synchronization.

The reference fixture recorded recovery times of 0.8 seconds, 1.5 seconds, and 3.2 seconds for 100, 500, and 1,000 symbols. Those values are environment-specific, but the relative trend is expected: larger subscription sets require more control-plane work during restoration.

Connection drops
      │
      ▼
Backoff and jitter
      │
      ▼
Reconnect with URL authentication
      │
      ▼
Resubscribe to symbols and channels
      │
      ▼
Validate sequence state
      │
      ▼
Refresh stale books
      │
      ▼
Resume signal processing

Do not resume a strategy immediately after a successful WebSocket handshake. A successful connection does not prove that the order-book state is current.

Rate-Limit and Error Handling

The client must distinguish recoverable conditions from permanent configuration errors.

Error Meaning Response
1001 / 1002 Invalid or missing API key Stop and repair authentication
2002 Symbol not found Validate symbols before retrying
3001 Rate limit exceeded Wait for the specified retry interval
Timeout No required response within the deadline Reconnect with backoff and jitter
Parse error Unexpected message format Log the payload schema, preserve diagnostics
Sequence gap Missing intermediate update Resubscribe or refresh state

A reconnect loop without jitter can cause several workers to reconnect simultaneously after a provider restart. Jitter spreads those retries and reduces synchronized load spikes.

Choosing a Safe Subscription Count

There is no meaningful universal number without a latency and workload definition. A practical deployment decision should follow this sequence:

1. Separate control-plane size from data-plane load

Counting symbols is only the first step. Also record:

  • Depth levels per market
  • Updates per symbol per second
  • Average payload size
  • Number of subscribed channels
  • Trading-session overlap
  • Client parsing and persistence work

2. Test the production symbol set

A thousand liquid instruments may generate much less traffic than one hundred highly active instruments. The benchmark should use the actual universe rather than an arbitrary list of tickers.

3. Set latency thresholds before running the test

A strategy with a 50 ms order cancellation window cannot accept a p99 of 300 ms simply because the connection remains open. Define acceptable p95 and p99 values in advance.

4. Test reconnect behavior

A subscription set that cannot be restored quickly may be operationally worse than a smaller set with faster recovery.

5. Scale across connections only when necessary

If one connection misses its latency target, distributing symbols across multiple processes can improve isolation. It does not remove provider-side load, and it adds complexity:

  • Subscription ownership must remain consistent.
  • Aggregated latency must still be measured.
  • Reconnection storms must be staggered.
  • Cross-symbol features may require a shared state layer.
  • Authentication and symbol deduplication become operational concerns.

The correct unit of capacity is therefore a subscribed instrument universe with defined behavior, not a raw symbol count.

Deployment Recommendations by Use Case

User segment Recommended starting configuration Validation focus
Individual developer 50–100 symbols on one connection Ease of use, latency stability, basic recovery
Small quantitative team 200–500 symbols per worker Message rate, memory growth, reconnect orchestration
Institutional strategy platform 500–1,000 symbols per worker after load testing Tail latency, sequencing, provider behavior, failover
Market-data redistributor Multiple isolated workers with explicit partitioning Throughput, backpressure, auditability, recovery time

These are starting ranges, not product limits. A production deployment should be sized from observed message rates and latency targets.

Capacity Checklist

Before approving a configuration, verify that the test covers all of the following:

  • The connection uses the correct WebSocket authentication parameter.
  • The API key comes from a protected environment variable.
  • The test uses the actual production symbol universe.
  • Warm-up data is excluded from steady-state measurements.
  • p50, p95, p99, and maximum latency are reported.
  • Message loss or sequence gaps are detected.
  • CPU and memory are sampled continuously.
  • Forced-disconnect recovery is tested.
  • Rate-limit handling is exercised.
  • The full-book refresh procedure is documented.
  • The strategy remains paused until state synchronization completes.
  • Thresholds are evaluated independently for each connection count.

Conclusion

A single WebSocket connection can carry substantially more than a casual symbol count, but “unlimited” does not remove the physical limits of message delivery, client processing, and operational recovery. The controlled reference test remained stable at 1,000 subscriptions, yet its p95 latency and memory footprint were materially higher than at 100 or 500 symbols.

The practical lesson is straightforward: choose a subscription count from a measured latency and workload envelope, not from the provider’s policy language. A 500-symbol configuration that maintains a 39 ms p95 latency and restores within 1.5 seconds may be more valuable than a 1,000-symbol configuration whose tail latency exceeds the strategy’s timeout budget.

Next Steps

If you are an individual quant developer, begin with a focused universe of 50–100 symbols, run the client above, and record p95 latency, throughput, memory growth, and recovery time before expanding.

If you operate a quantitative research team, use separate workers for distinct symbol groups and stagger reconnection attempts with exponential backoff and jitter. Compare results across several consecutive sessions rather than one best run.

If you need historical OHLCV data for strategy research, TickDB provides cleaned and aligned US equity kline data covering more than ten years, with separate endpoints for historical bars and the current real-time candle.

If you are evaluating institutional capacity, contact [email protected] with your expected universe size, message rate, latency target, and deployment topology.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool’s marketplace.

This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.