C.W.K.
Stream
Lesson 01 of 05 · published

Polling → Long-Polling — Cheap Real-Time Before WebSockets

~10 min · streaming-async, polling, long-polling

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Before SSE and WebSockets, the only way to get server-pushed updates was to ask repeatedly. Naive polling is wasteful; long-polling fixes the wastefulness; both still ship in production today for cases where the higher-tech options don't fit."

Naive Polling — Ask Repeatedly, Throw Most Answers Away

Client asks the server "any updates?" every N seconds. If nothing changed, the server returns an empty result; client waits and asks again. Simple to implement; works with any HTTP server; survives every proxy in the world.

Costs:

  • Latency. Average time-to-deliver = N/2. Polling every 5s means user-visible latency between 0 and 5s, average 2.5s.
  • Bandwidth. Empty responses still cost an HTTP roundtrip (request line + headers + 200 OK + empty body = ~500 bytes both ways). At 5s intervals across 10,000 clients, that's 1 MB/s of nothing.
  • Server load. Even idle clients hit the endpoint constantly.

When it's fine: low-stakes status checks (build progress every 10s), background workers, anywhere latency tolerance is high.

Long-Polling — Server Holds the Request

Same pattern, smarter execution: client asks; server doesn't respond immediately if nothing's available. Instead, the server holds the connection open, waiting for an event. When something happens (or a server-side timeout fires, ~30s), the server responds with the event (or empty + 'timeout' marker). The client immediately starts a new long-poll.

Wins over naive polling:

  • Near-zero latency. The moment data is available, server pushes it. Client receives it within one RTT.
  • Lower request rate. One request per event (or per ~30s timeout) instead of one request per 5s.

Costs:

  • One persistent connection per pending client. Server holds N file descriptors for N waiting clients. Async/await frameworks (FastAPI, Node, Go) handle this cheaply; thread-per-request servers (older PHP, Apache prefork) do not.
  • Proxy/timeout interference. Some intermediaries close connections after ~60s of silence. Server-side timeout must come before any intermediary's.

The Async Cost Inversion

In a synchronous (thread-per-request) server, holding 10,000 long-poll connections requires 10,000 threads — typically infeasible. In an async server (Python's asyncio, Node.js, Go goroutines), 10,000 pending connections cost almost nothing — each is a stalled coroutine, not a thread. The framework's concurrency model determines whether long-polling is practical at scale.

Long-polling is the simplest 'real-time' pattern that works everywhere HTTP works. It needs no protocol upgrade, no special middleware, no client-side library. When you can't use SSE or WebSocket (corporate firewall blocks, ancient client), long-polling is the fallback that just works.

When Long-Polling Wins vs SSE / WebSocket

Long-polling beats SSE when:

  • Clients are behind firewalls that drop long-held connections (some corporate environments) — but in that case SSE has the same problem.
  • You need request/response semantics for each event (the client wants to acknowledge each delivery). SSE is one-way push only.

Long-polling loses to SSE/WebSocket when:

  • You expect high event frequency. Each event re-opens the long-poll — that's per-event HTTP overhead SSE doesn't have.
  • You want true streaming (server pushes a continuous flow of small events). SSE was designed for this.
  • You need bidirectional traffic. Both directions need to flow real-time → WebSocket.

cwkPippa's Streaming Stack

cwkPippa uses neither polling nor long-polling — it ships SSE for AI token streams (the canonical use case: server-push high-frequency events to the WebUI). The fallback for non-streaming endpoints is normal request/response. WebSocket exists for the Cinder Sidekick channel (true bidirectional). Long-polling was never needed because every client we've shipped to supports SSE natively. The pattern still ships in 2026 — Pushpin, Pusher, and many SaaS notification services use long-polling as a fallback when SSE/WebSocket fail.

Code

Naive polling — ask every N seconds, throw most responses away·python
# Naive polling — client side
import httpx, time

while True:
    resp = httpx.get('https://api.example.com/inbox/poll?since_id=last_seen')
    events = resp.json()['events']
    for event in events:
        handle(event)
    time.sleep(5)  # poll every 5 seconds

# Wasteful: most polls return empty.
Long-polling server (FastAPI asyncio) — hold the request until data or timeout·python
# Long-polling — server side (FastAPI + asyncio)
import asyncio
from fastapi import FastAPI, Request

app = FastAPI()
_event_queues: dict[str, asyncio.Queue] = {}

@app.get('/inbox/poll')
async def long_poll(client_id: str, request: Request):
    queue = _event_queues.setdefault(client_id, asyncio.Queue())
    # Wait up to 30s for an event (server-side timeout)
    try:
        event = await asyncio.wait_for(queue.get(), timeout=30.0)
        return {'event': event}
    except asyncio.TimeoutError:
        # No event in 30s — return empty so client re-polls immediately
        return {'event': None}

# When something happens, push to the queue (any consumer):
async def publish_to(client_id: str, event: dict):
    queue = _event_queues.get(client_id)
    if queue:
        await queue.put(event)

# Client side — same shape as naive polling, but no sleep needed:
# while True:
#     resp = httpx.get('https://api.example.com/inbox/poll', params={'client_id': 'me'})
#     event = resp.json()['event']
#     if event: handle(event)
#     # immediately loop — server holds the next request
Long-polling client — tight loop, no sleep needed·javascript
// Long-polling — client side (browser fetch in a loop)
async function longPoll(clientId) {
  while (true) {
    try {
      const resp = await fetch(`/inbox/poll?client_id=${clientId}`);
      const data = await resp.json();
      if (data.event) {
        handle(data.event);
      }
      // No sleep — immediately re-poll. Server holds until next event or timeout.
    } catch (e) {
      // Network error — back off briefly to avoid hammering a flapping server
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}

longPoll('me');

External links

Exercise

Build a FastAPI long-poll endpoint that holds GET /events?since_id=X until either a new event arrives or 30 seconds pass. Use an asyncio.Queue per client_id. Then write a tiny publisher (POST /publish that puts events into the matching queue) and verify a long-polling client receives events within ~10ms of publish — versus naive polling at 5s intervals, where it would wait up to 5s. Measure both numerically with timestamps.
Hint
The asyncio pattern: a dict mapping client_id → asyncio.Queue, await queue.get() with asyncio.wait_for timeout. Publishing is queue.put_nowait(event). Client: tight loop with fetch + immediate re-fetch on response. Numerical comparison: publish at t=0, client receives at t=~RTT (long-poll) vs t=~2.5s average (naive 5s polling). The difference is dramatic enough to feel.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.