# Polling, Long Polling or SSE: What to Choose in Python?

> Polling, long polling or Server-Sent Events? How they work, what they really cost, HTTP caching and recovery after drops, with practical FastAPI examples.

- URL: https://dev-flow.io/en/posts/polling-long-polling-sse/
- Date: 2026-10-05
- Author: Anthony
- Language: en
- Categories: Architecture
- Tags: Python, Architecture, Api, Fastapi, Sse
- Translations: [fr](https://dev-flow.io/posts/polling-long-polling-sse/index.md), [es](https://dev-flow.io/es/posts/polling-long-polling-sse/index.md)

---


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.

```python
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 current
```

The `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](/en/posts/django-materialized-views-cache/).

On the client, prefer `setTimeout` to `setInterval`. With `setInterval`, slow requests overlap when the server is struggling, at the worst possible moment:

```javascript
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.

```python
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]}
```

```javascript
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](/en/posts/redis-pub-sub-vs-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.

```text
Polling       Client → ?  Server → no  |  Client → ?  Server → no  |  Client → ?  Server → yes
Long polling  Client → ?  ........ Server → yes  |  Client → ?  .... Server → yes
SSE           Client → ?  ........ Server → yes ...... yes .. yes ........ yes
```

The 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](/en/posts/server-sent-events-vs-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](/en/posts/server-sent-events-vs-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?".

