An order changes status, an export finishes, a message arrives. The server knows, the browser doesn’t. Polling, long polling and Server-Sent Events (SSE) all answer that same question, and they often get mixed up because the result on screen looks alike.
What separates them is who triggers the delivery and what silence costs, when nothing changes. Let’s look at how each one works, what it really costs, and why the simplest solution is often the right one.
In short:
- Polling: simple and cacheable, when a few seconds of delay are acceptable.
- Long polling: responsive without a continuous stream, useful behind a buffering proxy.
- SSE: the right choice to push frequent events to a browser.
Polling: the client keeps asking
The client queries the server at regular intervals. Nothing more, and that is its great strength: a plain HTTP route, regular workers, no long-lived connections.
import hashlib
import json
from typing import Annotated
from fastapi import FastAPI, Header, Response, status
app = FastAPI()
@app.get("/orders/{order_id}/status")
async def order_status(
order_id: int,
response: Response,
if_none_match: Annotated[str | None, Header()] = None,
):
current = await load_status(order_id)
body = json.dumps(current, sort_keys=True).encode()
etag = f'"{hashlib.sha256(body).hexdigest()[:16]}"'
if if_none_match == etag:
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag})
response.headers["ETag"] = etag
response.headers["Cache-Control"] = "no-cache"
return currentThe ETag avoids sending an identical body: if nothing has changed, the server responds 304 with no content. Be careful about what this really saves. Bandwidth goes down, but the request still happened and load_status still ran. If that read is heavy, that is what you need to lighten, for example with a materialized view.
On the client, prefer setTimeout to setInterval. With setInterval, slow requests overlap when the server is struggling, at the worst possible moment:
async function poll() {
const response = await fetch("/orders/42/status");
if (response.ok) renderStatus(await response.json());
setTimeout(poll, 5000 + Math.random() * 1000);
}The browser handles If-None-Match on its own through its HTTP cache, and hands your code a 200 even when the server answered 304. The random delay keeps all clients from hitting the server in the same second.
Polling has one asset the other two lack: shared caching. For data that is identical for everyone, a Cache-Control: public, max-age=5 on a CDN turns thousands of requests into a single one to your server every five seconds.
Its flaw is mathematical. With a 5-second interval, the average delay is 2.5 seconds, and 10,000 clients produce 2,000 requests per second, even if nothing moves. Reducing the delay means multiplying the load.
Long polling: the server holds its response
Long polling keeps a request open until there is something to say. The client sends its position, the server waits, responds as soon as an event arrives, and the client immediately starts again.
from typing import Annotated
from fastapi import FastAPI, Query, Response, status
from redis.asyncio import Redis
app = FastAPI()
redis = Redis.from_url("redis://localhost:6379", decode_responses=True)
@app.get("/orders/{order_id}/updates")
async def order_updates(
order_id: int,
after: Annotated[str, Query(pattern=r"^\d+-\d+$")] = "0-0",
wait: Annotated[int, Query(ge=1, le=25)] = 25,
):
stream = f"orders:{order_id}:events"
batches = await redis.xread({stream: after}, block=wait * 1000, count=100)
if not batches:
return Response(status_code=status.HTTP_204_NO_CONTENT)
_, messages = batches[0]
return {"events": [fields for _, fields in messages], "next": messages[-1][0]}async function longPoll(after) {
try {
const response = await fetch(`/orders/42/updates?after=${after}`);
if (response.status === 200) {
const { events, next } = await response.json();
events.forEach(renderEvent);
after = next;
}
longPoll(after);
} catch {
setTimeout(() => longPoll(after), 2000 + Math.random() * 3000);
}
}The after cursor is no minor detail. Between two requests, the client is no longer listening: without a position to resume from, any event published at that moment would be lost. You therefore need a source that keeps history, which rules out a plain broadcast. That is the whole difference between Redis Pub/Sub and Streams.
The wait parameter is capped at 25 seconds for a practical reason: proxies and load balancers cut off requests that stay silent too long (Nginx after 60 seconds by default, some platforms as early as 30 seconds). It is better to answer 204 cleanly and let the client start again.
Long polling has its own costs too. Under WSGI, each waiting request ties up a worker: it requires an asynchronous server. And each event costs a full HTTP round trip, headers included. The count parameter limits the damage by grouping bursts, but throughput remains lower than with a continuous stream.
It remains valuable where a continuous stream struggles: behind a buffering proxy, or for a client that doesn’t need a browser. The Telegram Bot API exposes getUpdates, which is long polling with an offset that plays exactly the role of our cursor.
SSE: a single response that never ends
SSE takes the idea all the way: instead of responding and then waiting for a new request, the server keeps the same response open and writes each event to it in the text/event-stream format.
Polling Client → ? Server → no | Client → ? Server → no | Client → ? Server → yes
Long polling Client → ? ........ Server → yes | Client → ? .... Server → yes
SSE Client → ? ........ Server → yes ...... yes .. yes ........ yesThe browser provides for free what long polling makes you write by hand: the EventSource API reconnects on its own and sends the last position back in the Last-Event-ID header. The FastAPI and Django implementation, and the comparison with WebSocket, have their own article.
In exchange, each client keeps a connection open, HTTP caching no longer helps, and every proxy along the way has to let the stream through without holding it back.
The real cost, in numbers
Take 10,000 clients and one event per client per minute, or about 167 events per second in total. This is an order of magnitude, assuming polling every 5 seconds and long polling capped at 25 seconds.
| Polling (5 s) | Long polling | SSE | |
|---|---|---|---|
| Requests per second | 2,000 | About 500 | Almost none, apart from reconnections |
| Useful requests | 1 in 12 | 1 in 3 | Not applicable |
| Average delay | 2.5 s | Near immediate | Near immediate |
| Open connections | Very few | About 10,000 | 10,000 |
| Shared HTTP cache | Yes | No | No |
| Server | WSGI or ASGI | ASGI | ASGI |
| Reconnection and resumption | Nothing to manage | To be written | Built into EventSource |
| Browser HTTP/1.1 limit | No notable effect | Holds a connection | Holds a connection |
The table shows that the real comparison isn’t about request counts alone. Polling consumes CPU and network constantly, while the other two consume connections and memory. And 10,000 open connections weigh very little as long as an asynchronous loop manages them, but they complicate every deployment: on restart, all clients come back at the same time.
Polling, long polling or SSE: how to choose
Start from how fresh the data needs to be, and take the simplest option that delivers it.
Polling suits cases where a few seconds of delay bother nobody, where data is shared and cacheable, or where the infrastructure must not change. A dashboard refreshed every 30 seconds needs nothing else.
Long polling suits cases that need responsiveness but where a continuous stream doesn’t get through: buffering corporate proxies, clients that aren’t browsers, simple server-side integrations.
SSE suits any browser that must receive frequent events with little delay: notifications, progress, LLM responses displayed as they are generated.
And if the client must also emit continuously, you leave this trio for WebSocket.
One last piece of advice: in every case, keep a full read of the state. A resumption can fail, a cursor can be too old. The stream says “something has changed”, and the regular read remains the source of truth.
Conclusion
Polling, long polling and SSE are not three levels of quality, but three ways of distributing a cost. Polling pays on each check, long polling on each event, SSE once at connection and then over time in connections to maintain.
So the right question is not “which technique is the most modern?”, but “how fresh does this really need to be, and what am I willing to operate to get there?”.
