Launch Week: Full course free — Claim your copy →
← Back to blog

Stream Live Sports Data with WebSockets in Python

2026-03-27 websocket python data live tutorial

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:

  1. Latency: You only see updates every N seconds. A score change at second 1 is invisible until second 5.
  2. Waste: Most requests return the same data. During timeouts or halftime, nothing changes for minutes.
  3. 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.

Get free trading insights

Module 1 (ESPN scraping) free + weekly edge reports.

Want to build this yourself?

The ZenHodl course teaches you to build a complete prediction market bot in 6 notebooks.

Join the community

Discuss strategies, share results, get help.

Join Discord