Loading...

Case study

Live polls at scale: the cache-and-purge pattern

A poll on a live stream is the worst possible read pattern: every viewer wants the same answer at the same second, and the moment the host opens a new question they all ask again. Polling the backend does not survive that. This is how we run polls on yayında.tv and in-player interactions on Videoonly with roughly one origin request per change, using nothing more exotic than a cached URL and a purge call.

9 Intermediate Updated

Live polls at scale: the cache-and-purge pattern

Why a live poll breaks the obvious approach

The obvious implementation is an endpoint the page calls every few seconds. It works in testing with five people and falls apart in production, because a live audience is synchronised in a way normal web traffic never is.

Everyone is watching the same moment. The host opens a question and thirty thousand browsers ask for it inside the same second. They all want an identical answer, so you are running the same query thirty thousand times and returning the same bytes. Then they keep asking every few seconds for the rest of the broadcast, whether anything changed or not.

The usual escape is WebSockets: hold a connection per viewer and push. That works, but you have taken on connection state, scaling a socket tier, reconnect storms when a mobile network hiccups, and a fallback path for clients that cannot hold a connection.

There is a simpler option, and it is the one we run in production.

The pattern: one cached URL, purged on change

Poll state is small, identical for everyone, and changes rarely — a handful of times per broadcast. That is exactly the shape a CDN is built for.

So publish it as a plain cacheable JSON URL. Viewers fetch that URL and are answered by the nearest edge. Your origin sees the first request in each location and essentially nothing after that, no matter how many people are watching.

When the host opens, closes or edits a question, the backend purges that one URL. The next viewer request misses at the edge, fetches the new state once, and every viewer after that is served from cache again.

The interesting property is what happens as the audience grows: nothing. Ten thousand viewers or a hundred thousand, the origin still sees one request per change per location. Cost tracks how often the poll changes, not how many people are watching.

How it looks in yayında.tv

yayında.tv is our live broadcast platform, and this is the whole mechanism.

The read side is one endpoint that returns the active survey with its questions and answers as JSON, served through the CDN. It sends permissive CORS headers so the player can fetch it from whichever domain the broadcast is embedded on. The URL carries a hash derived from the channel name and a server-side secret, which keeps it from being trivially enumerable while staying perfectly cacheable — the URL is stable for a given channel, so it is a real cache key rather than a per-user one.

The write side is a few lines in the controller that starts or stops a survey: build the same URL, call the purge API, done. There is no queue, no fan-out, no socket tier — the CDN is the fan-out.

The two halves
// READ — what every viewer hits, cached at the edge
GET https://cdn.example.tv/{channel}/survey?s={hash}

{
  "status": "success",
  "survey": {
    "id": 42,
    "questions": [
      { "text": "Who takes the penalty?",
        "answers": [{"id": 1, "text": "..."}, {"id": 2, "text": "..."}] }
    ]
  }
}

// WRITE — the broadcaster opens the poll; the backend purges that one URL
cdnctl purge --account <account_uuid> \
             --path "/{channel}/survey?s={hash}" --type exact

// or straight from the app, on the same event that flips the poll live

And in the player: Videoonly

Videoonly applies the same idea inside the video player. In-player interactions — a question overlay, a sponsor card, a call to action timed to a moment in the stream — are all the same shape of problem: a small piece of state that every viewer needs, that changes when an operator decides it changes.

So the player fetches its overlay configuration from a cached URL rather than asking the API on a timer. When someone schedules or pulls an overlay, that URL is purged. The player picks it up on its next fetch, and the API is not in the hot path at all.

The same reasoning applies to anything else with these properties: a scoreboard, a "now playing" strip, feature flags for a live event, a countdown that can be cut short.

Getting the details right

A few things decide whether this is smooth or annoying in practice.

**Keep the URL stable.** Anything per-user in the path or query — a session id, a cache-buster timestamp — gives every viewer their own cache key and you are back to hitting the origin for everyone. Personalisation belongs in a second, separate request, not in the shared one.

**Set a TTL you can live with anyway.** The purge is what makes changes fast, but a finite TTL is your safety net for the day a purge call fails. Short enough that a missed purge is a blip, long enough that the origin stays quiet.

**Purge the exact URL you serve.** If the cached object is `?s=abc` and you purge the path without the query, nothing is invalidated and the old poll stays up. This is the most common way the pattern silently fails.

**Separate reads from writes.** Viewers read the cached URL; votes go to a normal, uncached endpoint. Writes are a fraction of reads — a viewer votes once and reads continuously — so the write path can stay ordinary.

**Do not put secrets in the URL hash.** It is obfuscation, not authorisation: enough to stop casual enumeration, and it must stay compatible with caching. Anything that genuinely needs protecting does not belong in a shared cached object.

When to reach for WebSockets instead

This pattern is not a universal replacement, and it is worth being clear about where it stops.

It fits when the state is shared by everyone, small, and changes on human timescales — polls, overlays, scoreboards, flags. It does not fit when each viewer needs different data, when updates are continuous rather than occasional, or when you need sub-second delivery with ordering guarantees. Live chat is the obvious counter-example: per-user, constant, and latency-sensitive. Use a socket tier there.

The honest framing is that most "real-time" features on a broadcast page are not real-time at all. They are occasional changes to shared state, and a cached URL plus a purge handles them with a fraction of the moving parts.

Questions people ask

How quickly do viewers see the change?

As fast as the purge propagates plus the client's next fetch. In practice that is a second or two — well inside what a broadcast needs, since the host is talking over the transition anyway.

What if a purge call fails?

That is what the TTL is for. A failed purge means the change lands when the object expires instead of immediately. Keep the TTL short enough that this is a delay rather than an outage, and log purge failures so you notice a pattern.

Does this work with query strings in the URL?

Yes, as long as the CDN includes the query string in the cache key and you purge the exact URL you serve. Purging the path alone when the object is cached with a query string is the classic mistake.

Where do the votes go?

To a normal uncached endpoint. Only the shared read is cached. Votes are a tiny fraction of the traffic, because each viewer votes once and reads continuously.

Is this cheaper than WebSockets?

Usually, and the bigger saving is operational. There is no socket tier to scale, no connection state to hold, no reconnect storm when a mobile network drops. The origin cost tracks how often the poll changes rather than how many people are watching.