The Fundamental Difference Nobody Explains Clearly
The question sounds simple: "Should I use REST or WebSocket?" Ask ten developers and you will get ten variations of the same answer — REST for historical data, WebSocket for real-time streams. But why? Why not fetch real-time prices over REST in a tight loop? Why not subscribe to historical candles over WebSocket?
The answer lives in how these two protocols handle connection semantics, state management, and network economics. Understanding these three dimensions transforms API selection from a rule-of-thumb into an architectural decision. This article dissects the tradeoffs, shows production-grade code for both patterns in TickDB, and provides a decision framework for quant teams building data pipelines.
The Connection Model: A Tale of Two Philosophies
REST: Request-Response with No Memory
REST is stateless by design. Every request is independent. The client sends a request; the server processes it; the connection closes; the server forgets the client existed. This simplicity is a feature, not a limitation.
For historical data retrieval, this model aligns perfectly with the use case:
Client: "Give me AAPL US hourly candles from 2024-01-01 to 2024-06-30"
Server: "Here is the data. Done. Goodbye."
The server does not need to track your position in a dataset. It does not need to maintain a session. It receives a well-defined request, returns a well-defined response, and the transaction concludes cleanly.
WebSocket: Persistent Connection with Bidirectional Traffic
WebSocket inverts the model. After an initial handshake, the connection stays open indefinitely. Both client and server can send messages at any time. The server maintains a live view of which symbols you have subscribed to and pushes updates when those symbols change.
For real-time streaming, this model is optimal:
Client: "Subscribe me to NVDA US depth updates"
Server: "Acknowledged. I will push you order book snapshots as they occur."
Server: [push] Order book changed — here is the new L1 snapshot
Server: [push] Order book changed — here is the new L1 snapshot
Server: [push] Order book changed — here is the new L1 snapshot
The server now maintains state on your behalf — your subscription list, your last-known sequence number, your heartbeat status.
The Cost of Crossing the Paradigm
Why REST Fails for Real-Time Streaming
Imagine fetching real-time order book updates over REST polling. Every 100 milliseconds, your client sends:
GET /v1/market/depth?symbol=AAPL.US
Three problems emerge immediately.
Problem 1: Connection overhead. Each request establishes a new TCP connection (unless you implement connection pooling, which adds complexity). At 10 requests per second, that is 10 connection setups per second per client. Scale to 100 clients and you have 1,000 connection establishments per second hitting the server.
Problem 2: Data staleness during the request. A REST poll captures a snapshot at the moment of the request. By the time your code processes the response, the order book has moved. For high-frequency order flow analysis, a 50 ms processing delay means you are analyzing yesterday's data.
Problem 3: No push on change. REST cannot notify you when something interesting happens. Your polling interval determines your latency floor. A 100 ms poll means 100 ms maximum latency — but also means 99.9% of your requests return identical data, wasting bandwidth and server resources.
Why WebSocket Fails for Historical Retrieval
Now consider subscribing to historical OHLCV candles over WebSocket:
Client: "Please stream me 10 years of AAPL hourly candles"
Server: [push] Here is candle 1 of 87,648...
Server: [push] Here is candle 2 of 87,648...
Server: [push] Here is candle 3 of 87,648...
... 87,645 messages later ...
Four problems emerge immediately.
Problem 1: Stateful server burden. The server must now track your session across potentially hours of streaming. If the connection drops at candle 43,287, the server must either resume from that point (requiring server-side state) or you must restart from candle 1 (wasteful).
Problem 2: No natural pagination boundary. REST APIs have clean pagination — page=1, page=2. WebSocket streams have no inherent demarcation. When do you stop waiting for more data?
Problem 3: Backpressure management. A fast server streaming to a slow client creates a backlog. The server must buffer messages, allocate memory per session, and handle clients that read slowly or not at all.
Problem 4: Error recovery is messy. What happens when candle 55,432 fails validation? In a REST response, you reject the entire batch and request again. In a WebSocket stream, you have already delivered 55,431 valid messages. Do you restart? Do you skip? The protocol has no standard answer.
TickDB Architecture: Matching Protocol to Use Case
TickDB exposes both REST and WebSocket interfaces, but each serves a distinct role in the data architecture.
| Use case | Recommended protocol | Rationale |
|---|---|---|
| Historical OHLCV retrieval (backtesting) | REST | Stateless, paginated, resumable |
| Current period candle (live dashboard) | REST /kline/latest |
Single snapshot, no subscription needed |
| Real-time order book (depth) | WebSocket depth channel |
Push-on-change, persistent connection |
| Real-time trade ticks | WebSocket trades channel |
High-frequency, event-driven |
| Symbol discovery | REST /symbols/available |
One-time lookup, stateless |
| Batch data export | REST with streaming response | Large payloads, connection reuse via pagination |
The kline endpoint over REST is the correct choice for strategy backtesting. The depth and trades channels over WebSocket are the correct choice for live execution monitoring.
Production-Grade Code: REST Pattern
Fetching Historical OHLCV for Backtesting
The following Python code demonstrates production-grade REST usage for historical candle retrieval. It includes exponential backoff on rate limits, timeout configuration, and proper environment variable handling for API authentication.
import os
import time
import requests
from typing import Generator, Dict, Any, Optional
class TickDBRestClient:
"""Production-grade TickDB REST client for historical data retrieval."""
BASE_URL = "https://api.tickdb.ai/v1"
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not self.api_key:
raise ValueError(
"API key not provided. Set TICKDB_API_KEY environment variable."
)
def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
retries: int = 3,
) -> Dict[str, Any]:
"""Execute HTTP request with rate-limit handling and exponential backoff."""
url = f"{self.BASE_URL}{endpoint}"
headers = {"X-API-Key": self.api_key}
base_delay = 1.0
max_delay = 30.0
for attempt in range(retries):
try:
response = requests.request(
method=method,
url=url,
headers=headers,
params=params,
timeout=(3.05, 30), # (connect_timeout, read_timeout)
)
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 5))
print(f"Rate limited. Retrying after {retry_after} seconds.")
time.sleep(retry_after)
continue
response.raise_for_status()
data = response.json()
# Check application-level error codes
if data.get("code") != 0:
raise RuntimeError(
f"TickDB API error {data.get('code')}: {data.get('message')}"
)
return data
except requests.exceptions.Timeout:
print(f"Request timeout (attempt {attempt + 1}/{retries}). Retrying.")
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = time.uniform(0, delay * 0.1)
time.sleep(delay + jitter)
except requests.exceptions.RequestException as e:
print(f"Request failed (attempt {attempt + 1}/{retries}): {e}")
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = time.uniform(0, delay * 0.1)
time.sleep(delay + jitter)
raise RuntimeError(f"Request failed after {retries} attempts")
def fetch_klines(
self,
symbol: str,
interval: str = "1h",
start_time: Optional[int] = None,
end_time: Optional[int] = None,
limit: int = 1000,
) -> Generator[Dict[str, Any], None, None]:
"""
Fetch historical OHLCV candles for backtesting.
Args:
symbol: Market symbol (e.g., "AAPL.US", "BTC.Binance")
interval: Candle interval ("1m", "5m", "1h", "1d", etc.)
start_time: Unix timestamp in milliseconds (inclusive)
end_time: Unix timestamp in milliseconds (exclusive)
limit: Max candles per request (server-enforced maximum)
Yields:
Dictionary with o, h, l, c, v, t fields for each candle
"""
params = {
"symbol": symbol,
"interval": interval,
"limit": limit,
}
if start_time:
params["start_time"] = start_time
if end_time:
params["end_time"] = end_time
while True:
data = self._request("GET", "/market/kline", params=params)
candles = data.get("data", [])
if not candles:
break
for candle in candles:
yield candle
# Paginate if we hit the limit and have a time range
if len(candles) < limit:
break
# Move start_time forward to the last candle's timestamp + 1ms
last_ts = candles[-1]["t"]
params["start_time"] = last_ts + 1
# Safety: stop if start_time exceeds end_time
if end_time and params["start_time"] >= end_time:
break
# Example: Fetch AAPL hourly candles for backtesting
if __name__ == "__main__":
client = TickDBRestClient()
print("Fetching AAPL.US hourly candles for 2024...")
candles = list(
client.fetch_klines(
symbol="AAPL.US",
interval="1h",
start_time=int(1704067200000), # 2024-01-01 00:00 UTC
end_time=int(1735689600000), # 2025-01-01 00:00 UTC
)
)
print(f"Retrieved {len(candles)} candles")
# First and last candle timestamps
if candles:
print(f"Period: {candles[0]['t']} to {candles[-1]['t']}")
Engineering notes:
- The client uses a generator pattern for pagination, allowing processing of large datasets without loading everything into memory.
- Time-based pagination (
start_time) ensures consistent candle boundaries across requests. - The timeout tuple
(3.05, 30)sets a 3.05-second connect timeout and a 30-second read timeout — critical for handling slow network conditions without hanging indefinitely. # ⚠️ For production HFT workloads exceeding 100 symbols, consider connection pooling with urllib3 and async I/O via aiohttp.
Production-Grade Code: WebSocket Pattern
Subscribing to Real-Time Order Book Depth
The following Python code demonstrates production-grade WebSocket usage for real-time order book streaming. It includes heartbeat management, exponential backoff with jitter on reconnection, subscription management, and clean shutdown handling.
import os
import json
import time
import random
import threading
import websocket
from typing import Callable, Dict, Any, Optional
class TickDBWebSocketClient:
"""
Production-grade TickDB WebSocket client for real-time data streaming.
Handles:
- Heartbeat (ping/pong)
- Exponential backoff + jitter on reconnect
- Rate-limit handling
- Subscription management
- Clean shutdown
"""
WS_URL = "wss://api.tickdb.ai/ws/v1/market"
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not self.api_key:
raise ValueError(
"API key not provided. Set TICKDB_API_KEY environment variable."
)
self.ws: Optional[websocket.WebSocketApp] = None
self.running = False
self.subscriptions: set = set()
self.last_pong_time: float = 0
self.reconnect_delay: float = 1.0
self.max_reconnect_delay: float = 60.0
self._thread: Optional[threading.Thread] = None
self._handlers: Dict[str, Callable] = {}
def _on_message(self, ws, message: str):
"""Handle incoming WebSocket messages."""
try:
data = json.loads(message)
# Handle pong responses
if data.get("type") == "pong":
self.last_pong_time = time.time()
return
# Handle error messages
if "code" in data and data["code"] != 0:
error_msg = data.get("message", "Unknown error")
print(f"[ERROR] Code {data['code']}: {error_msg}")
# Handle rate limit
if data["code"] == 3001:
retry_after = int(data.get("retry_after", 5))
print(f"[RATE LIMIT] Waiting {retry_after} seconds before retry.")
time.sleep(retry_after)
return
# Dispatch to registered handlers
channel = data.get("channel")
if channel and channel in self._handlers:
self._handlers[channel](data)
except json.JSONDecodeError:
print(f"[ERROR] Failed to decode message: {message}")
def _on_error(self, ws, error: str):
"""Handle WebSocket errors."""
print(f"[WS ERROR] {error}")
def _on_close(self, ws, close_status_code: int, close_msg: str):
"""Handle WebSocket disconnection."""
print(f"[WS CLOSED] Code: {close_status_code}, Message: {close_msg}")
self.running = False
def _on_open(self, ws):
"""Handle WebSocket connection establishment."""
print("[WS CONNECTED]")
# Re-establish subscriptions after reconnect
for symbol in list(self.subscriptions):
self._send_subscribe(symbol, "depth")
# Reset reconnect delay on successful connection
self.reconnect_delay = 1.0
def _send_subscribe(self, symbol: str, channel: str):
"""Send subscription message to server."""
msg = {
"cmd": "sub",
"channel": f"{channel}:{symbol}",
}
self.ws.send(json.dumps(msg))
print(f"[SUBSCRIBED] {channel}:{symbol}")
def subscribe(
self, symbol: str, channel: str = "depth", handler: Optional[Callable] = None
):
"""
Subscribe to a symbol's channel.
Args:
symbol: Market symbol (e.g., "AAPL.US", "BTC.Binance")
channel: Channel name ("depth", "trades")
handler: Callback function to handle incoming data
"""
channel_key = f"{channel}:{symbol}"
self.subscriptions.add(channel_key)
if handler:
self._handlers[channel_key] = handler
if self.ws and self.running:
self._send_subscribe(symbol, channel)
def _heartbeat_loop(self):
"""Send periodic ping messages to keep connection alive."""
while self.running:
if self.ws:
try:
self.ws.send(json.dumps({"cmd": "ping"}))
time.sleep(25) # Send ping every 25 seconds
except Exception as e:
print(f"[HEARTBEAT ERROR] {e}")
break
def _reconnect_loop(self):
"""Attempt to reconnect with exponential backoff + jitter."""
while self.running:
if not self.ws or not self.running:
delay = self.reconnect_delay
jitter = random.uniform(0, delay * 0.1)
wait_time = delay + jitter
print(f"[RECONNECT] Attempting in {wait_time:.2f} seconds...")
time.sleep(wait_time)
# Exponential backoff: double delay each failure
self.reconnect_delay = min(
self.reconnect_delay * 2, self.max_reconnect_delay
)
self._connect()
def _connect(self):
"""Establish WebSocket connection with API key in URL."""
url = f"{self.WS_URL}?api_key={self.api_key}"
self.ws = websocket.WebSocketApp(
url,
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close,
on_open=self._on_open,
)
self.running = True
# Run WebSocket in a separate thread to avoid blocking
self._thread = threading.Thread(target=self.ws.run_forever, daemon=True)
self._thread.start()
def start(self):
"""Start the WebSocket client."""
self._connect()
# Start heartbeat thread
heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
heartbeat_thread.start()
print("[CLIENT STARTED] WebSocket connected. Use ctrl+c to stop.")
def stop(self):
"""Gracefully stop the WebSocket client."""
print("[CLIENT STOPPING] Closing connection...")
self.running = False
if self.ws:
self.ws.close()
if self._thread:
self._thread.join(timeout=5)
# Example: Real-time order book monitoring
if __name__ == "__main__":
import signal
client = TickDBWebSocketClient()
def handle_depth(data: Dict[str, Any]):
"""Process incoming depth snapshots."""
symbol = data.get("symbol", "UNKNOWN")
bids = data.get("b", []) # Bid levels
asks = data.get("a", []) # Ask levels
# Calculate top-of-book spread
if bids and asks:
best_bid = float(bids[0][0])
best_ask = float(asks[0][0])
spread_bps = (best_ask - best_bid) / best_bid * 10000
print(
f"[{symbol}] Bid: {best_bid:.2f} | Ask: {best_ask:.2f} | "
f"Spread: {spread_bps:.1f} bps"
)
# Subscribe to depth channel
client.subscribe(symbol="AAPL.US", channel="depth", handler=handle_depth)
client.subscribe(symbol="TSLA.US", channel="depth", handler=handle_depth)
# Handle graceful shutdown
def signal_handler(signum, frame):
print("\nReceived interrupt signal. Shutting down...")
client.stop()
exit(0)
signal.signal(signal.SIGINT, signal_handler)
# Start the client
client.start()
# Keep main thread alive
while client.running:
time.sleep(1)
Engineering notes:
- The heartbeat loop sends a
pingcommand every 25 seconds. If the server does not respond with apongwithin a reasonable window, the connection is considered dead. - The reconnection loop uses exponential backoff (
1s → 2s → 4s → ... → 60s max) with 10% jitter to prevent thundering herd when many clients reconnect simultaneously after a server outage. - Subscription state is maintained in
self.subscriptions. When reconnecting, the client automatically re-establishes all prior subscriptions without requiring the application to re-track them. - The client runs WebSocket I/O in a daemon thread, allowing the main thread to handle other tasks or wait for shutdown signals.
# ⚠️ For production HFT workloads with sub-10ms latency requirements, consider a C++ or Rust WebSocket implementation with direct memory management. Python's GIL introduces inherent latency floors for high-frequency message processing.
The Buy/Sell Pressure Ratio: Connecting Depth Data to Strategy
The depth channel delivers snapshots of the order book. Raw snapshots are informative, but the actionable signal is derived from the buy/sell pressure ratio.
Definition:
Pressure Ratio = Σ(bid sizes, top N levels) / Σ(ask sizes, top N levels)
- Ratio > 1.0: Buy pressure dominates. The bid side has more resting volume.
- Ratio < 1.0: Sell pressure dominates. The ask side has more resting volume.
- Ratio inversion (crossing from >1 to <1): Potential short-term momentum shift.
A simple implementation using the TickDB depth channel:
def calculate_pressure_ratio(bids: list, asks: list, levels: int = 5) -> float:
"""
Calculate buy/sell pressure ratio from order book depth.
Args:
bids: List of [price, size] pairs for bid levels
asks: List of [price, size] pairs for ask levels
levels: Number of price levels to include (default 5)
Returns:
Pressure ratio (bid_total / ask_total)
"""
bid_total = sum(float(bid[1]) for bid in bids[:levels])
ask_total = sum(float(ask[1]) for ask in asks[:levels])
if ask_total == 0:
return float('inf') # Infinite bid pressure (no asks)
return bid_total / ask_total
def on_depth_update(data: dict):
"""Example handler for depth channel updates."""
symbol = data.get("symbol")
bids = data.get("b", [])
asks = data.get("a", [])
ratio = calculate_pressure_ratio(bids, asks, levels=5)
# Classify pressure regime
if ratio > 1.5:
regime = "STRONG_BUY"
elif ratio > 1.1:
regime = "MODERATE_BUY"
elif ratio > 0.9:
regime = "NEUTRAL"
elif ratio > 0.67:
regime = "MODERATE_SELL"
else:
regime = "STRONG_SELL"
print(f"[{symbol}] Pressure Ratio: {ratio:.2f} | Regime: {regime}")
Decision Framework: Choosing the Right Protocol
Use this decision tree when architecting your TickDB data pipeline.
| Question | If Yes | If No |
|---|---|---|
| Do you need data that existed before the current moment? | REST /kline |
Continue |
| Are you building a backtesting pipeline that will run once over historical data? | REST | Continue |
| Do you need a snapshot of the current candle or order book? | REST /kline/latest |
Continue |
| Do you need updates that will arrive continuously over time? | WebSocket | Continue |
| Will your system run for hours or days, receiving continuous market updates? | WebSocket | Continue |
| Are you processing more than 50 symbols simultaneously with real-time data? | WebSocket (mandatory) | Continue |
| Is your use case one-time or low-frequency lookup (symbol list, exchange info)? | REST | WebSocket |
Comparison: REST vs WebSocket on TickDB
| Dimension | REST | WebSocket |
|---|---|---|
| Connection model | Short-lived, stateless | Long-lived, stateful |
| Data direction | Client pulls | Server pushes |
| Best for | Historical data, snapshots, discovery | Real-time depth, trade ticks |
| Pagination | Native (cursor or time-based) | Not natively supported |
| Error recovery | Retry any request independently | Must track missed messages |
| Rate limits | Handled per-request | Per-connection |
| Heartbeat | Not needed | Required for keepalive |
| Reconnection cost | None (stateless) | Must re-subscribe |
| Memory per client | Minimal | Moderate (subscription state) |
| Scalability model | Horizontal (add servers) | Complex (stateful sessions) |
Common Mistakes to Avoid
Mistake 1: Polling REST for real-time data.
# DON'T: Polling REST in a tight loop
while True:
data = requests.get(f"{BASE}/depth?symbol=AAPL.US", headers=headers)
process(data)
time.sleep(0.1) # 100ms minimum latency floor
This approach wastes bandwidth, hits rate limits faster, and introduces unnecessary latency. Switch to WebSocket depth subscription.
Mistake 2: Using WebSocket for bulk historical downloads.
# DON'T: Subscribing for historical data
ws.send(json.dumps({"cmd": "sub", "channel": f"kline:{symbol}"}))
# Expecting 87,648 historical candles to stream in ...
WebSocket is not designed for bulk transfer. Use REST with pagination.
Mistake 3: Hardcoding API keys.
# DON'T: Hardcoded key
headers = {"X-API-Key": "tk_live_abc123xyz"}
# DO: Environment variable
headers = {"X-API-Key": os.environ.get("TICKDB_API_KEY")}
Mistake 4: No reconnection logic.
# DON'T: Assume connection is permanent
ws = websocket.WebSocket()
ws.connect(url)
# DO: Implement reconnection with backoff
Network connections drop. A production WebSocket client must handle reconnection gracefully.
Deployment Recommendations by User Segment
| Segment | Recommended pattern | Why |
|---|---|---|
| Individual quant researcher | REST for backtesting; WebSocket for live paper trading | Simple, covers 95% of use cases |
| Small team (2–5 quants) | REST + WebSocket with shared client library | Avoid duplicated reconnection logic |
| Institutional team | REST + WebSocket with connection manager, metric logging, circuit breakers | Resilience at scale |
| Algorithmic trading firm | REST + WebSocket in separate processes; message queue between data ingestion and strategy | Decouple data from logic for fault isolation |
Closing
The REST-vs-WebSocket decision is not arbitrary. It reflects the fundamental characteristics of your data access pattern: pull vs. push, stateless vs. stateful, bulk transfer vs. event streaming.
For TickDB, the rule is clean: REST for data that already happened; WebSocket for data that is happening now. Understanding why this split exists — in connection semantics, network economics, and state management — allows you to make principled architectural decisions rather than following rules blindly.
When in doubt, ask one question: "Am I asking for something that already exists, or waiting for something that has not happened yet?" The answer tells you which protocol to reach for.
Next Steps
If you are building a backtesting pipeline: Start with the REST /k1/market/kline endpoint. The generator pattern in the code example above handles pagination automatically for datasets spanning years.
If you are building a live execution monitor: Connect to the WebSocket depth and trades channels. The pressure ratio calculation in this article provides a foundation for order flow analysis.
If you want to test both patterns without writing code: Visit tickdb.ai and use the API explorer in the dashboard. Both REST and WebSocket endpoints are available with your free API key.
If you are an institutional team needing historical US equity OHLCV data spanning 10+ years: Reach out to enterprise@tickdb.ai for plan details covering extended data retention and higher rate limits.
If you use AI coding assistants: Search for and install the tickdb-market-data SKILL in your AI tool's marketplace for context-aware code generation when working with TickDB endpoints.
This article does not constitute investment advice. Market data APIs and streaming protocols involve technical complexity; validate all integration patterns in a paper trading environment before live deployment. Past performance of any strategy referenced does not guarantee future results.