21. WebSockets vs polling for live price updates in crypto apps
Live price updates are the heartbeat of a crypto app. The question isn’t “real-time or not,” but how to deliver updates reliably: WebSockets, polling, or a hybrid.
This post compares both approaches and frames the choice using the current stack in this project.
1. The two delivery models
1.1 Polling
- The client requests price data every N seconds.
- Simple to implement and easy to cache.
- Scales well with stateless HTTP.
1.2 WebSockets
- The server pushes updates continuously.
- Lower latency and smoother UI.
- Requires persistent connections and additional infrastructure.
2. What polling looks like in this repo
In this repo, the backend exposes polling-style endpoints that fetch data from the Go worker and persist it.
# backend/src/internal_api/coinone_worker_public_api_router.py
@coinone_worker_public_api_router.get("/trades")
async def fetch_trades(
db: AsyncSession = Depends(get_async_db),
client: httpx.AsyncClient = Depends(get_http_client),
quote: str = Query(...),
target: str = Query(...),
):
q, t = norm(quote), norm(target)
url = f"{GO_WORKER_BASE}/api/public/trades/{urlquote(q)}/{urlquote(t)}"
payload = await fetch_go_worker_json(client, url, expected=dict, retries=1)
svc = TradesService(db)
summary = await svc.ingest_payload(
exchange_name=DEFAULT_EXCHANGE,
quote_currency=q,
target_currency=t,
payload=payload,
chunk_size=1000,
on_conflict="nothing",
)
return JSONResponse(content={**summary, "persisted": True})This is a classic polling workflow: HTTP request -> fetch -> validate -> store.
3. Reliability features baked into polling
The HTTP client is configured with explicit timeouts and connection limits:
# backend/src/infra/http_client.py
DEFAULT_TIMEOUT = httpx.Timeout(connect=1.0, read=5.0, write=3.0, pool=1.0)
DEFAULT_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)
def build_http_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
http2=True,
timeout=DEFAULT_TIMEOUT,
headers={"Accept": "application/json"},
limits=DEFAULT_LIMITS,
)Retries and backoff are handled in the fetch helper:
# backend/src/utils/go_worker.py
async def fetch_go_worker_json(..., retries=1, backoff=0.2):
for attempt in range(retries + 1):
try:
resp = await client.get(url)
except httpx.HTTPError as e:
if attempt < retries:
await asyncio.sleep(backoff * (2 ** attempt))
continue
raise HTTPException(status_code=502, detail=f"go-worker http error: {e}")
if 500 <= resp.status_code < 600 and attempt < retries:
await asyncio.sleep(backoff * (2 ** attempt))
continueThis makes polling resilient without requiring special logic on the client side.
4. What WebSockets would change
If I add WebSockets later, the data flow shifts from request/response to server push:
- Exchange stream -> server -> client
- The backend must manage connection state, replay, and incremental updates (deltas).
At that point, the ingestion logic above still matters, but the delivery pipeline changes:
- pushing deltas (not full snapshots)
- tracking sequence IDs for replay
- handling reconnects using a “since” cursor (or a last-seen sequence ID)
5. When polling is still the right choice
Polling is a great baseline when:
- you want a simple, reliable UI
- you don’t have streaming infrastructure yet
- you want to reuse existing REST endpoints (and benefit from caching)
In this repo, polling already fits the ingestion model, so it’s the natural starting point.