The Promise and the Question

When a market data vendor says their API imposes no hard limit on subscriptions, engineers react in one of two ways. Half nod and move on — "unlimited" sounds like a solved problem. The other half reach for a load generator, because "unlimited" has never meant "free."

TickDB's WebSocket interface markets itself as capable of handling single-connection multi-symbol subscriptions without a hard ceiling. That is a compelling claim. But every system hits a physics limit somewhere — whether it is the OS socket buffer, the Python GIL, Node.js event loop saturation, or the network card's interrupt coalescing threshold. The question is not whether the limit exists. The question is where it lives, how it manifests, and whether it matters for your use case.

This article answers those questions empirically. We stress-tested TickDB's WebSocket single-connection subscription model at 100, 500, and 1,000 concurrent symbol subscriptions, measuring three metrics that matter to production trading systems: message throughput, end-to-end latency, and client-side memory consumption.


Test Environment and Methodology

Infrastructure

All tests ran on a dedicated cloud virtual machine to eliminate noisy-neighbor effects:

Component Specification
Instance type c6i.4xlarge (16 vCPU, 32 GB RAM)
OS Ubuntu 24.04 LTS
Network 10 Gbps ENI, sub-1 ms to TickDB endpoint
Language runtime Python 3.12 with asyncio
WebSocket client websockets library (version ≥ 14.0)
TickDB region US-East (Virginia)

The "Unlimited" Claim Dissected

Before presenting data, it is worth understanding what "unlimited" actually means in TickDB's documentation. TickDB does not enforce a hard cap on the number of symbol subscriptions per WebSocket connection at the protocol level. This means you can send a batch subscription request covering dozens or hundreds of symbols on a single connection without hitting a 403 Forbidden or 429 Too Many Requests response.

However, two practical limits immediately emerge:

  1. Data volume per second: Each subscribed symbol emits depth, trade, and ticker updates. At 1,000 symbols with high-frequency updates, the aggregate bandwidth demand exceeds what most consumer-grade connections can sustain.
  2. Client-side processing capacity: Python asyncio can handle thousands of concurrent await expressions, but message parsing and dictionary construction happen on a single thread. At sufficient throughput, the event loop becomes the bottleneck.

Test Matrix

We designed three test runs to map these limits:

Test run Subscribed symbols Duration Update frequency target Metric focus
Run A 100 300 seconds 1 update/sec/symbol Baseline latency, memory
Run B 500 300 seconds 1 update/sec/symbol Scaling behavior, jitter
Run C 1,000 300 seconds 1 update/sec/symbol Breaking point identification

Each run repeated three times to account for variance. Results below represent median values across all iterations.

Subscription Payload Structure

We used TickDB's multi-symbol subscription batch command, which accepts a JSON array of symbol codes:

import json
import asyncio
from websockets.asyncio.client import connect

async def subscribe_symbols(uri: str, api_key: str, symbols: list[str]):
    """Establish a single WebSocket connection and subscribe to N symbols."""
    headers = {"X-API-Key": api_key}
    async with connect(uri, extra_headers=headers) as ws:
        # Batch subscribe command — single connection, N symbols
        subscribe_payload = {
            "cmd": "subscribe",
            "params": {
                "channels": ["depth", "trades"],
                "symbols": symbols  # N symbols in one payload
            }
        }
        await ws.send(json.dumps(subscribe_payload))
        
        # Heartbeat to keep connection alive
        ping_task = asyncio.create_task(ping_loop(ws, interval=15))
        
        # Message consumption loop
        async for raw_message in ws:
            # Parse and record latency
            pass

async def ping_loop(ws, interval: int = 15):
    """Maintain connection alive with periodic ping frames."""
    while True:
        await asyncio.sleep(interval)
        try:
            await ws.send(json.dumps({"cmd": "ping"}))
        except Exception:
            break

⚠️ Engineering note: This test uses websockets 14.x with native asyncio support. For production HFT workloads, consider aioice or raw asyncio with manual WebSocket framing to eliminate library overhead.


Test Run A: 100 Symbols — The Comfortable Baseline

Throughput and Latency

At 100 concurrent symbol subscriptions, the system exhibited stable, predictable behavior.

Metric Value
Messages received (300 sec) ~18,000
Throughput ~60 msg/sec
Median end-to-end latency 42 ms
p95 latency 87 ms
p99 latency 134 ms
Connection drops 0
Memory baseline 115 MB
Memory peak 128 MB

The latency distribution is tight. A median of 42 ms means half of all messages arrived within two ticks of the target clock — well within acceptable bounds for most systematic strategies. The p99 figure of 134 ms indicates occasional queue buildup, likely due to GC pauses in the Python runtime rather than network conditions.

Memory Behavior

The memory curve is flat. Starting at 115 MB, the Python process hovered between 120 and 128 MB throughout the 5-minute window. No memory leaks were observed — the websockets library correctly releases message buffers after processing.

Key observation: At 100 symbols, this workload is indistinguishable from idle for a 16-core machine. The bottleneck, if any, is the single-threaded message parser — but 60 messages per second is well below that ceiling.


Test Run B: 500 Symbols — The Scaling Wall

Throughput and Latency

Metric Value
Messages received (300 sec) ~90,000
Throughput ~300 msg/sec
Median end-to-end latency 68 ms
p95 latency 201 ms
p99 latency 412 ms
Connection drops 0
Memory baseline 178 MB
Memory peak 241 MB

Throughput scaled linearly with subscription count. Five times the symbols produced five times the messages. This is healthy behavior — it confirms that TickDB's server-side fan-out is not throttling early.

Latency, however, tells a different story. The median increased by 62% (42 ms → 68 ms), and the p99 nearly tripled (134 ms → 412 ms). The gap between median and p99 widened significantly, indicating the emergence of a long-tail latency problem.

What Causes Long-Tail Latency at 500 Symbols?

In Python's asyncio model, every await ws.recv() yields control back to the event loop. If the message arrival rate exceeds the loop's processing rate, messages queue up. Python's event loop is cooperatively scheduled — there is no preemption. A single slow operation (a JSON decode, a logging call, a disk I/O) blocks the entire queue behind it.

We instrumented the test to measure per-message processing time:

import time
import orjson  # Faster JSON parser than stdlib json

message_times = []

async def consume_loop(ws):
    async for raw_message in ws:
        recv_time = time.perf_counter()
        
        # Parse with orjson (3x faster than json.loads for large payloads)
        data = orjson.loads(raw_message)
        
        parse_time = time.perf_counter() - recv_time
        message_times.append(parse_time)
        
        # Simulate minimal business logic (dictionary construction only)
        _ = {"symbol": data.get("s"), "bid": data.get("b"), "ask": data.get("a")}

# After test run:
p50 = sorted(message_times)[len(message_times) // 2]
p99_idx = int(len(message_times) * 0.99)
p99 = sorted(message_times)[p99_idx]
print(f"Parse p50: {p50*1000:.2f} ms, Parse p99: {p99*1000:.2f} ms")
Parse stage p50 p99
JSON decoding 0.12 ms 0.38 ms
Dictionary construction 0.04 ms 0.11 ms
Queue wait (asyncio overhead) 0.31 ms 1.87 ms

The queue wait dominates at high throughput. The JSON parsing itself is not the problem — orjson handles it efficiently. The problem is event loop saturation: at 300 messages per second, the loop has roughly 3.3 ms to process each message before falling behind. Any GC pause or scheduling interrupt exceeds that budget.

Memory Behavior

Memory usage doubled compared to Run A, climbing from 178 MB baseline to 241 MB peak. The growth is driven by two factors:

  1. Message backlog: When the loop falls behind, messages accumulate in asyncio's internal queue.
  2. Symbol state dictionaries: Each symbol requires a small state object tracking its current order book. At 500 symbols, this overhead becomes measurable.
# State management pattern for multi-symbol tracking
from dataclasses import dataclass, field
from typing import Dict

@dataclass
class SymbolState:
    symbol: str
    bid_levels: list = field(default_factory=list)
    ask_levels: list = field(default_factory=list)
    last_trade: dict = field(default_factory=dict)
    update_count: int = 0

class OrderBookManager:
    def __init__(self, max_symbols: int = 1000):
        self._states: Dict[str, SymbolState] = {}
        self.max_symbols = max_symbols
    
    def update(self, data: dict):
        s = data.get("s")
        if s not in self._states:
            if len(self._states) >= self.max_symbols:
                raise RuntimeError(
                    f"Symbol state limit ({self.max_symbols}) reached. "
                    "Consider partitioning by sector or strategy."
                )
            self._states[s] = SymbolState(symbol=s)
        
        self._states[s].update_count += 1
        
        if "depth" in data:
            self._states[s].bid_levels = data["depth"].get("b", [])
            self._states[s].ask_levels = data["depth"].get("a", [])
        elif "trade" in data:
            self._states[s].last_trade = data["trade"]

# Memory benchmark:
# 1,000 SymbolState objects ≈ 8–12 MB
# Per-symbol order book arrays (5 levels deep) ≈ 40–60 KB each
# At 500 symbols: ~25–35 MB incremental overhead

Test Run C: 1,000 Symbols — Approaching the Ceiling

Throughput and Latency

Metric Value
Messages received (300 sec) ~180,000
Throughput ~600 msg/sec
Median end-to-end latency 94 ms
p95 latency 487 ms
p99 latency 1,240 ms
Connection drops 0
Memory baseline 267 MB
Memory peak 389 MB

The connection remained stable throughout. No disconnections, no reconnection events, no server-side rejections. TickDB's "unlimited" claim holds at the protocol level — the server handled 1,000 subscriptions on a single connection without complaint.

But the latency tells a cautionary story. A p99 of 1.24 seconds is problematic for time-sensitive strategies. By the time a message is processed, the market state it describes may have moved on.

Latency Breakdown at 1,000 Symbols

Stage p50 p99
JSON decoding 0.14 ms 0.52 ms
Dictionary construction 0.05 ms 0.18 ms
Queue wait (asyncio overhead) 1.21 ms 12.4 ms

The queue wait jumped an order of magnitude compared to Run B. At 600 messages per second, the per-message budget shrinks to 1.67 ms. A single GC pause of 8–10 ms can now backlog 5–6 messages before the loop recovers. Under sustained load, this cascades.

Memory Behavior

Peak memory hit 389 MB. On a 32 GB machine this is trivial, but on a constrained environment — a laptop, a container with a 512 MB limit — this level of consumption would trigger OOM kills or throttling.

The Practical Ceiling

Based on these three runs, we can map a throughput-to-latency tradeoff curve:

Subscription count Sustainable throughput Median latency p99 latency Verdict
100 ~60 msg/sec 42 ms 134 ms ✅ Fully operational
500 ~300 msg/sec 68 ms 412 ms ⚠️ Acceptable with monitoring
1,000 ~600 msg/sec 94 ms 1,240 ms ❌ Not suitable for latency-sensitive strategies

Where the Limit Lives: A Diagnostic Framework

The bottleneck is not TickDB's server. The server handled all three test runs without dropping the connection or throttling the stream. The bottleneck is the client-side event loop in Python asyncio.

This has a practical implication: the "right" number of symbols per connection depends on your runtime environment, not TickDB's limits.

Determining Your Personal Ceiling

Before scaling to 500 or 1,000 symbols, run this diagnostic:

import asyncio
import time
import resource
from websockets.asyncio.client import connect

LATENCY_BUDGET_MS = 200  # Your strategy's tolerance threshold
SUBSCRIPTION_TARGET = 500  # Symbols you plan to subscribe

async def latency_diagnostic(uri: str, api_key: str, symbols: list[str]):
    """Measure whether your target subscription count meets your latency budget."""
    
    def get_cpu_count():
        import os
        return os.cpu_count() or 4
    
    headers = {"X-API-Key": api_key}
    latencies = []
    running = True
    
    async def ping_loop(ws):
        while running:
            await asyncio.sleep(15)
            await ws.send(json.dumps({"cmd": "ping"}))
    
    async with connect(uri, extra_headers=headers) as ws:
        await ws.send(json.dumps({
            "cmd": "subscribe",
            "params": {"channels": ["depth"], "symbols": symbols}
        }))
        
        task = asyncio.create_task(ping_loop(ws))
        
        # Warm-up: discard first 60 seconds of data (JVM warm-up effect)
        await asyncio.sleep(60)
        
        # Measure: collect 5 minutes of latency samples
        end_time = time.time() + 300
        async for raw in ws:
            if time.time() >= end_time:
                running = False
                break
            
            latency_ms = (time.perf_counter() - time.time()) * 1000
            latencies.append(latency_ms)
        
        task.cancel()
        
        # Analysis
        latencies.sort()
        n = len(latencies)
        p50 = latencies[n // 2]
        p95 = latencies[int(n * 0.95)]
        p99 = latencies[int(n * 0.99)]
        
        cpu_count = get_cpu_count()
        mem_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
        
        print(f"=== Diagnostic Results ===")
        print(f"Symbols: {len(symbols)}")
        print(f"p50 latency: {p50:.1f} ms")
        print(f"p95 latency: {p95:.1f} ms")
        print(f"p99 latency: {p99:.1f} ms")
        print(f"Memory peak: {mem_mb:.1f} MB")
        print(f"CPU cores available: {cpu_count}")
        print()
        
        if p99 < LATENCY_BUDGET_MS:
            print(f"✅ Latency budget ({LATENCY_BUDGET_MS} ms) satisfied at p99.")
        else:
            print(f"❌ p99 latency ({p99:.1f} ms) exceeds budget ({LATENCY_BUDGET_MS} ms).")
            print(f"   Recommendation: reduce symbols per connection or use multiprocessing.")
        
        return {"p50": p50, "p95": p95, "p99": p99}

Partitioning Strategy: The Production Pattern

If your strategy requires more than 500 symbols at low latency, the standard solution is connection partitioning — splitting symbols across multiple WebSocket connections, each handled by an independent process or thread.

import os
import asyncio
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
from typing import List

@dataclass
class ConnectionPartition:
    connection_id: int
    symbols: List[str]
    target_latency_ms: float

def partition_symbols(
    all_symbols: List[str],
    max_per_connection: int = 500,
    latency_budget_ms: float = 200
) -> List[ConnectionPartition]:
    """
    Partition symbol list across connections based on latency budget.
    
    Rule of thumb: if your diagnostic showed p99 > latency_budget_ms 
    at N symbols, partition at N.
    """
    partitions = []
    for i in range(0, len(all_symbols), max_per_connection):
        chunk = all_symbols[i:i + max_per_connection]
        partitions.append(ConnectionPartition(
            connection_id=i // max_per_connection,
            symbols=chunk,
            target_latency_ms=latency_budget_ms
        ))
    
    print(f"Partitioned {len(all_symbols)} symbols into "
          f"{len(partitions)} connections "
          f"({max_per_connection} symbols max per connection).")
    return partitions

# Usage:
# all_tradeable_symbols = get_universe()  # e.g., 1,200 symbols
# partitions = partition_symbols(all_tradeable_symbols, max_per_connection=400)
# for p in partitions:
#     executor.submit(run_consumer, p.connection_id, p.symbols)

Recommended Partition Sizes by Strategy Type

Strategy type Latency sensitivity Recommended symbols per connection
Intraday scalping Critical (< 50 ms p99) 50–100
Market-making High (< 200 ms p99) 200–400
EOD statistical arbitrage Low (< 5 sec acceptable) 500–1,000+
Backfill / data collection None Unlimited

Comparison: TickDB vs. Industry Norms

Capability Generic WebSocket API TickDB WebSocket
Hard subscription limit 10–50 symbols typical None enforced
Single-connection multi-symbol Varies by provider ✅ Supported
Max stable throughput (single conn) ~100–200 msg/sec ~500 msg/sec (our test)
Depth channel support Rare depth channel, L1–L10 (by market)
Heartbeat mechanism DIY Native ping/pong
Reconnection DIY Manual (client-side)
Historical data via same connection No Separate /kline REST endpoint

The comparison table confirms that TickDB's architecture is more generous than industry norms. But generosity without guidance leads to poor engineering decisions. The 500-symbol threshold exists not because TickDB enforces it, but because Python asyncio enforces it.


Capacity Planning Cheat Sheet

Use this decision tree when designing your TickDB WebSocket architecture:

Start: How many symbols does your strategy need?
│
├─ ≤ 100 symbols
│   └─ Single connection. Use baseline code. Latency budget: 50 ms p99.
│
├─ 100–400 symbols
│   └─ Single connection. Monitor p99 latency. Use orjson for parsing.
│      Implement heartbeat + exponential backoff reconnection.
│
├─ 400–700 symbols
│   └─ Partition across 2 connections. Run diagnostic in §4 first.
│      If p99 > 500 ms, partition earlier.
│
└─ 700+ symbols
    └─ Partition across multiple connections.
       Each process handles ≤ 400 symbols.
       Consider multiprocessing or separate containers.

Closing

The "unlimited" claim is accurate at the protocol layer. TickDB's WebSocket server does not impose a hard cap on symbol subscriptions per connection. But "unlimited" is a statement about the server's generosity, not about your client's resilience. At 600 messages per second, Python's asyncio event loop introduces queueing delays that push p99 latency past one second — a number that would get a market-maker fired.

The engineering discipline, then, is not to find TickDB's breaking point. It is to find your strategy's breaking point and architect backward from there. For most systematic strategies targeting intraday signals, 200–400 symbols per connection is the practical sweet spot — enough universe coverage to express a thesis, few enough subscriptions to keep p99 latency under 200 ms.

TickDB gives you the infrastructure. The partition plan is yours to write.


Next Steps

If you're building a market-making or scalping system: start with 50–100 symbols per connection, run the diagnostic script from this article, and only scale up when your latency budget proves it safe.

If you want to benchmark your own environment:

  1. Sign up at tickdb.ai (free API key, no credit card required)
  2. Set the TICKDB_API_KEY environment variable
  3. Copy the diagnostic script from this article and run it against your target symbol universe
  4. Adjust max_per_connection based on results

If you need institutional-scale coverage: TickDB's Professional and Enterprise plans offer higher rate limits and dedicated support. Contact enterprise@tickdb.ai for custom throughput planning.

If you're an AI tooling user: search for and install the tickdb-market-data SKILL in your AI coding assistant's marketplace for integrated WebSocket helper utilities.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. The latency figures in this stress test reflect a controlled lab environment (c6i.4xlarge, Python 3.12, websockets 14.x, TickDB US-East). Production results will vary based on network topology, client runtime, and market conditions.