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:
- 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.
- Client-side processing capacity: Python asyncio can handle thousands of concurrent
awaitexpressions, 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
websockets14.x with native asyncio support. For production HFT workloads, consideraioiceor rawasynciowith 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:
- Message backlog: When the loop falls behind, messages accumulate in asyncio's internal queue.
- 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:
- Sign up at tickdb.ai (free API key, no credit card required)
- Set the
TICKDB_API_KEYenvironment variable - Copy the diagnostic script from this article and run it against your target symbol universe
- Adjust
max_per_connectionbased 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.