If you are polling ESPN every 5 seconds for live scores, you are doing it the hard way. WebSockets give you push-based updates the instant something changes — no wasted requests, no missed events, lower latency.
This tutorial covers both approaches so you can pick the right one for your use case.
HTTP Polling: The Simple Approach
Most people start here. Hit the ESPN scoreboard API on a loop:
import requests
import time
def poll_scores(sport="basketball", league="nba", interval=5):
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
while True:
resp = requests.get(url)
data = resp.json()
for event in data["events"]:
status = event["status"]["type"]["state"]
if status == "in":
home = event["competitions"][0]["competitors"][0]
away = event["competitions"][0]["competitors"][1]
clock = event["status"]["displayClock"]
period = event["status"]["period"]
print(
f"{away['team']['abbreviation']} {away['score']} @ "
f"{home['team']['abbreviation']} {home['score']} "
f"Q{period} {clock}"
)
time.sleep(interval)
This works, but it has three problems:
- Latency: You only see updates every N seconds. A score change at second 1 is invisible until second 5.
- Waste: Most requests return the same data. During timeouts or halftime, nothing changes for minutes.
- Rate limits: ESPN will throttle you if you poll too aggressively across many sports.
For a trading bot, that 5-second latency window is where money is made and lost. Our execution quality analysis showed that tradable windows often last only 15-60 seconds. Losing 5 seconds to polling delay is a significant cost.
WebSockets: Push-Based Updates
A WebSocket connection stays open. The server pushes data to you when something changes. No polling, no wasted requests, near-instant updates.
Here is the pattern using Python's websockets library:
import asyncio
import websockets
import json
async def stream_scores(uri: str, callback):
"""Connect to a WebSocket sports data feed and process updates."""
async for ws in websockets.connect(uri):
try:
async for message in ws:
data = json.loads(message)
await callback(data)
except websockets.ConnectionClosed:
print("Connection lost, reconnecting...")
continue
async def handle_update(data):
"""Process a single score update."""
event_type = data.get("type")
if event_type == "score_change":
print(
f"SCORE: {data['away_team']} {data['away_score']} @ "
f"{data['home_team']} {data['home_score']} "
f"Q{data['period']} {data['clock']}"
)
elif event_type == "status_change":
print(f"STATUS: {data['game_id']} -> {data['new_status']}")
asyncio.run(stream_scores("wss://your-data-feed.example.com/scores", handle_update))
The async for ws in websockets.connect(uri) pattern handles reconnection automatically. If the connection drops, it reconnects and resumes — critical for multi-hour game sessions.
Building Your Own WebSocket Server
If you are scraping ESPN via HTTP but want WebSocket delivery to your bot, build a bridge: one process polls ESPN, another broadcasts updates over a local WebSocket:
import asyncio
import websockets
import json
import aiohttp
CLIENTS = set()
async def espn_poller():
"""Poll ESPN and broadcast changes to all connected WebSocket clients."""
prev_states = {}
url = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard"
async with aiohttp.ClientSession() as session:
while True:
async with session.get(url) as resp:
data = await resp.json()
for event in data.get("events", []):
game_id = event["id"]
home = event["competitions"][0]["competitors"][0]
away = event["competitions"][0]["competitors"][1]
current_state = {
"game_id": game_id,
"home_team": home["team"]["abbreviation"],
"away_team": away["team"]["abbreviation"],
"home_score": int(home["score"]),
"away_score": int(away["score"]),
"period": event["status"]["period"],
"clock": event["status"]["displayClock"],
"status": event["status"]["type"]["state"],
}
if current_state != prev_states.get(game_id):
prev_states[game_id] = current_state
message = json.dumps(current_state)
if CLIENTS:
await asyncio.gather(
*[client.send(message) for client in CLIENTS]
)
await asyncio.sleep(3)
async def ws_handler(websocket):
CLIENTS.add(websocket)
try:
await websocket.wait_closed()
finally:
CLIENTS.discard(websocket)
async def main():
async with websockets.serve(ws_handler, "localhost", 8765):
await espn_poller()
asyncio.run(main())
This architecture separates concerns. The poller handles ESPN rate limits and deduplication. Your trading bot connects to ws://localhost:8765 and receives only changed game states — no parsing, no polling logic, no rate limit management.
The Client Side
Your trading bot connects as a WebSocket client and reacts to each update:
import asyncio
import websockets
import json
async def trading_bot():
async for ws in websockets.connect("ws://localhost:8765"):
try:
async for message in ws:
game = json.loads(message)
if game["status"] != "in":
continue
# Feed game state to your WP model
features = extract_features(game)
fair_prob = model.predict(features)
# Compare to market price
market_price = get_market_price(game["game_id"])
edge = fair_prob - market_price
if edge > 0.08:
print(
f"SIGNAL: {game['away_team']}@{game['home_team']} "
f"fair={fair_prob:.2f} market={market_price:.2f} "
f"edge={edge:.2f}"
)
except websockets.ConnectionClosed:
continue
Every score change triggers an immediate model evaluation. No waiting for the next poll cycle.
Latency Comparison
We measured the end-to-end latency from a real scoring event to signal generation:
| Approach | Median Latency | P95 Latency |
|---|---|---|
| HTTP polling (5s) | 2.8s | 5.0s |
| HTTP polling (2s) | 1.2s | 2.0s |
| WebSocket bridge (3s poll) | 1.6s | 3.1s |
| Native WebSocket feed | 0.3s | 0.8s |
The WebSocket bridge with a 3-second ESPN poll sits in the middle — better than naive 5-second polling, not as fast as a native real-time feed. For most strategies, sub-2-second latency is sufficient because prediction market orderbooks take 5-30 seconds to fully reprice after a scoring event.
Handling Connection Failures
Live data feeds fail. Networks drop. Servers restart. Your bot needs to handle all of this gracefully:
import asyncio
import websockets
import json
from datetime import datetime, timedelta
async def resilient_stream(uri, callback, max_gap=timedelta(seconds=30)):
last_message = datetime.now()
async for ws in websockets.connect(uri, ping_interval=20, ping_timeout=10):
try:
async for message in ws:
last_message = datetime.now()
data = json.loads(message)
await callback(data)
except websockets.ConnectionClosed:
gap = datetime.now() - last_message
if gap > max_gap:
print(f"WARNING: {gap.seconds}s data gap — backfill needed")
# Fetch current state via HTTP to catch up
await backfill_current_state(callback)
continue
The max_gap check is important. If your WebSocket was down for 30+ seconds during a live game, you may have missed scoring events. A quick HTTP poll backfills the current state so your model does not trade on stale data.
When to Use Which Approach
HTTP polling is fine when: - You are backtesting or scraping historical data - You only track 1-2 sports with few concurrent games - Latency under 5 seconds is acceptable
WebSocket streaming is better when: - You trade on live prediction markets where seconds matter - You track many games concurrently across multiple sports - You need to react to score changes, not just observe them
For production trading bots, the WebSocket bridge architecture gives you the best balance: simple ESPN polling on the backend, instant delivery to your bot on the frontend.
Part of the ZenHodl blog. We write about sports analytics, prediction markets, and building trading bots with Python.