The polling problem that broke our view counter
The alert came in at 07:40 CET on a Tuesday. A French synth-pop clip we had surfaced the night before was doing roughly 40,000 concurrent viewers, and our origin was throwing 503s. Nothing about the video pages themselves was expensive — they are statically cached HTML served from the edge. The thing melting the box was a single innocent-looking widget: the live view counter under every thumbnail, polling /api/views/{id} every two seconds from every open tab.
At ViralVidVault we run a deliberately small stack — PHP 8.4, SQLite in WAL mode, LiteSpeed, and Cloudflare in front — because a European viral-video discovery site lives and dies on cheap, predictable reads. Polling broke that promise in the crudest way possible. Forty thousand browsers times one request every two seconds is 20,000 requests per second of pure counter traffic, and the overwhelming majority of those responses returned a number that had not changed since the last poll. We were burning worker slots, SQLite read locks, and Cloudflare requests to tell people nothing.
We replaced the whole thing with Server-Sent Events. This is the write-up I wish I had had that morning: why SSE beats both polling and WebSockets for a broadcast-only integer, how to stream a live counter out of SQLite WAL without grinding the disk, how to stop LiteSpeed from silently buffering your stream into uselessness, and how to fan a single origin stream out to tens of thousands of viewers at the edge with a Cloudflare Worker.
Why Server-Sent Events and not WebSockets
The instinct when someone says "live counter" is to reach for WebSockets. For our use case that would have been over-engineering, and I want to be specific about why.
A view counter is a strictly one-directional broadcast. The server has a number; the client displays it. The client never needs to say anything back over the same channel — increments come in on a separate, ordinary POST. That asymmetry matters:
-
SSE is plain HTTP. It rides over the same HTTP/2 connection Cloudflare already terminates, so there is no protocol upgrade, no separate port, and no special handling in the CDN. WebSockets need an
Upgradehandshake and are treated as a distinct connection type by most proxies. -
Automatic reconnection is built in. The browser's
EventSourcereconnects on its own after a drop, and it will replay theLast-Event-IDheader so you can resume. With WebSockets you write that reconnection and backoff logic yourself, every time. - It degrades gracefully behind flaky mobile networks. A dropped SSE stream is just a dropped HTTP response; the client retries. We serve a lot of European mobile traffic on trains and metros, and graceful retry beats a half-open socket.
-
It is trivially cacheable-adjacent. The stream itself is
no-cache, but everything around it stays on the normal HTTP path, so our existing edge rules and rate limiting apply unchanged.
The one real limitation of SSE is the six-connection-per-origin cap on HTTP/1.1. Over HTTP/2 — which Cloudflare gives us for free — that limit effectively disappears because streams are multiplexed over a single TCP connection. Since every ViralVidVault visitor comes through Cloudflare on HTTP/2 or HTTP/3, the classic SSE gotcha never bites us.
Counting views without collecting PII
Before any of the streaming code, the counting itself has to be GDPR-clean, because we are a European product and I do not want a lawful-basis argument over a vanity number. Our rule is simple: a view counter must never touch personal data. We do not log IP addresses against video IDs, we do not set an identifying cookie to dedupe, and we do not join the counter table to anything that could reconstruct a person.
The counter is a single integer per video, incremented atomically. Deduplication — making sure one person scrubbing a video does not inflate the count — happens at the edge with a short-lived, non-identifying token that never reaches our database. The origin only ever sees "video X got one more view." Here is the schema; note there is nothing in it a regulator could object to:
-- schema.sql — the entire footprint of the live counter
CREATE TABLE IF NOT EXISTS counters (
video_id TEXT PRIMARY KEY,
views INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0 -- unix seconds, for staleness only
) STRICT;
-- No user_id, no ip, no session. Just a number and when it last moved.
CREATE INDEX IF NOT EXISTS idx_counters_updated ON counters(updated_at);
The increment endpoint is equally boring by design. It uses an UPSERT so there is no read-modify-write race under concurrency — the whole thing is one atomic statement, which is exactly what you want when a viral video is being watched thousands of times a second:
<?php
// ingest-view.php — atomic, PII-free view increment
declare(strict_types=1);
$videoId = filter_input(INPUT_POST, 'v', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if (!$videoId) {
http_response_code(400);
exit;
}
$db = new PDO('sqlite:/var/www/vault/data/views.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA journal_mode = WAL;');
$db->exec('PRAGMA synchronous = NORMAL;'); // safe under WAL, far fewer fsyncs
$db->exec('PRAGMA busy_timeout = 2000;');
// UPSERT: one round trip, no read-modify-write race between writers
$db->prepare('
INSERT INTO counters (video_id, views, updated_at)
VALUES (:v, 1, unixepoch())
ON CONFLICT(video_id) DO UPDATE
SET views = views + 1,
updated_at = unixepoch()
')->execute(['v' => $videoId]);
http_response_code(204);
WAL mode is the load-bearing decision here. With journal_mode = WAL and synchronous = NORMAL, writers append to a write-ahead log and readers never block writers. That is precisely the access pattern of a live counter — one increment path writing constantly, many stream endpoints reading constantly — and it is the reason SQLite comfortably absorbs a viral spike on a single box where a naive rollback-journal setup would deadlock under the read/write contention.
The SSE endpoint in PHP 8.4
Now the stream itself. An SSE endpoint is a long-lived PHP request that holds the connection open, checks the counter on an interval, and writes a framed event only when the number actually changes. The critical details are the headers, the buffer handling, and knowing when to hang up.
<?php
// sse-views.php - streams live view counts for a single video
declare(strict_types=1);
$videoId = filter_input(INPUT_GET, 'v', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if (!$videoId) {
http_response_code(400);
exit;
}
// SSE headers. X-Accel-Buffering is what stops LiteSpeed buffering the stream.
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
// Tear down every output buffer so bytes leave PHP the instant we flush.
while (ob_get_level() > 0) {
ob_end_flush();
}
ob_implicit_flush(true);
$db = new PDO('sqlite:/var/www/vault/data/views.db', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$db->exec('PRAGMA journal_mode = WAL;');
$db->exec('PRAGMA busy_timeout = 2000;');
$stmt = $db->prepare('SELECT views FROM counters WHERE video_id = :v');
$last = -1;
$start = time();
// Tell the client how long to wait before reconnecting (milliseconds).
echo "retry: 5000\n\n";
while (!connection_aborted()) {
$stmt->execute(['v' => $videoId]);
$views = (int) ($stmt->fetchColumn() ?: 0);
if ($views !== $last) {
$last = $views;
$payload = json_encode(['v' => $videoId, 'views' => $views], JSON_THROW_ON_ERROR);
echo "event: views\n";
echo 'id: ' . $views . "\n"; // becomes Last-Event-ID on reconnect
echo 'data: ' . $payload . "\n\n";
flush();
} elseif ((time() - $start) % 15 === 0) {
// Comment-only heartbeat keeps idle streams alive through proxies.
echo ": ping\n\n";
flush();
}
// Cap connection lifetime; EventSource reconnects transparently.
if (time() - $start > 300) {
break;
}
usleep(1_000_000); // poll the local WAL once a second - cheap, no network
}
A few things are deliberate. First, we only emit a views event when the number changes, so a video sitting at a stable count sends nothing but the occasional heartbeat. That is the entire efficiency win over polling: silence is free. Second, we set an id: on each event so that a reconnecting client sends Last-Event-ID and we could, if we wanted, skip straight to the current value. Third, we cap the connection at five minutes and let EventSource reconnect. Long-lived PHP requests tie up a LiteSpeed worker for their entire duration, so a hard lifetime ceiling keeps a slow leak of dead connections from starving the pool. Reconnection is invisible to the user — the counter does not flicker.
The usleep(1_000_000) is reading local SQLite, not making a network call, so a one-second poll against WAL is genuinely cheap. If you need sub-second freshness you can drop it, but for a human staring at a view count, one second is already faster than they can perceive.
The browser side — only stream what is on screen
The client is where a naive implementation quietly reopens the original problem. If you open an EventSource for every video card on a discovery page — and our homepage renders 60 of them — you have traded 60 polling loops for 60 open streams. The fix is to only subscribe to counters that are actually visible, using an IntersectionObserver, and to tear the stream down when the card scrolls away.
// live-views.js - attach a live counter only to cards on screen
const streams = new Map();
const formatCount = (n) =>
new Intl.NumberFormat('en-GB', { notation: 'compact' }).format(n);
function subscribe(videoId, el) {
if (streams.has(videoId)) return;
const source = new EventSource(
`/sse-views.php?v=${encodeURIComponent(videoId)}`,
);
source.addEventListener('views', (e) => {
const { views } = JSON.parse(e.data);
el.textContent = formatCount(views);
el.classList.add('vw-pulse');
setTimeout(() => el.classList.remove('vw-pulse'), 400);
});
source.onerror = () => {
// EventSource retries on its own. We only clean up a card that left the DOM.
if (!el.isConnected) {
source.close();
streams.delete(videoId);
}
};
streams.set(videoId, source);
}
function unsubscribe(videoId) {
const source = streams.get(videoId);
if (source) {
source.close();
streams.delete(videoId);
}
}
// Stream only what is visible: fewer connections, less battery, less origin load.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const id = entry.target.dataset.videoId;
if (entry.isIntersecting) {
subscribe(id, entry.target.querySelector('.vw-count'));
} else {
unsubscribe(id);
}
}
},
{ rootMargin: '200px' },
);
document
.querySelectorAll('[data-video-id]')
.forEach((el) => observer.observe(el));
The rootMargin: '200px' pre-subscribes cards just before they enter the viewport so the counter is already live by the time the user sees it. On a typical scroll session this keeps us to a handful of open streams per tab instead of dozens, and it stops phones from holding sixty connections open while the screen is off. This one detail did more for our battery-drain complaints than any backend change.
Making LiteSpeed cooperate with long-lived streams
SSE has a specific failure mode on production web servers that is maddening to debug because everything looks correct locally: the server buffers your stream and delivers it all at once when the connection closes, so the client sees nothing for five minutes and then a burst. Your code is fine; the server is helpfully batching output you explicitly wanted unbatched.
There are three levers, and you generally need all three:
-
X-Accel-Buffering: noin the response headers tells LiteSpeed (and nginx) not to buffer this response. This is the single most important line and the one people forget. -
Tear down PHP's own output buffers with the
ob_end_flush()loop before you start streaming, then callflush()after every event. If anyob_*buffer survives, PHP holds the bytes regardless of what the server does. -
Disable compression for the stream. LiteSpeed will happily gzip a
text/event-stream, and a compression buffer reintroduces the exact batching you just removed. Theno-transformin ourCache-Controlheader plus aSetEnvIfrule that turns gzip off for the SSE path keeps the bytes flowing.
On LiteSpeed specifically, also check that your per-request timeout is longer than the stream lifetime you chose in PHP. We cap streams at 300 seconds in code and set the server timeout comfortably above that, so PHP always decides when to close, not the server yanking the connection out from under us mid-event.
Fanning out at the edge with Cloudflare Workers
Everything above scales fine into the low thousands of concurrent viewers on a single origin box. A genuine viral spike — the 40,000-viewer Tuesday — is a different animal. Forty thousand open PHP streams means forty thousand occupied LiteSpeed workers, and no amount of WAL cleverness saves you from that. The number itself is identical for every viewer of a given video, so holding forty thousand separate origin connections to broadcast one integer is pure waste.
The fix is to collapse them at the edge. A Cloudflare Worker with a Durable Object per video keeps exactly one upstream SSE connection to our origin and fans it out to every subscriber. Origin sees one stream per hot video; the edge absorbs the crowd.
// worker.js - one origin stream per video, fanned out to N viewers
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname !== '/sse-views') {
return fetch(request); // everything else passes straight through
}
const videoId = url.searchParams.get('v');
if (!videoId) return new Response('missing v', { status: 400 });
// One Durable Object instance per video id.
const stub = env.VIEW_HUB.get(env.VIEW_HUB.idFromName(videoId));
return stub.fetch(request);
},
};
export class ViewHub {
constructor(state, env) {
this.env = env;
this.clients = new Set();
this.upstream = null;
}
async fetch(request) {
const videoId = new URL(request.url).searchParams.get('v');
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
this.clients.add(writer);
this.ensureUpstream(videoId); // starts one shared origin stream if needed
return new Response(readable, {
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
});
}
async ensureUpstream(videoId) {
if (this.upstream) return; // already streaming from origin for this video
const origin = `https://origin.viralvidvault.com/sse-views.php?v=${videoId}`;
const resp = await fetch(origin, { headers: { accept: 'text/event-stream' } });
this.upstream = resp.body.getReader();
// Relay every origin chunk to all connected viewers; prune dead writers.
while (true) {
const { value, done } = await this.upstream.read();
if (done) break;
for (const writer of this.clients) {
writer.write(value).catch(() => this.clients.delete(writer));
}
}
this.upstream = null;
this.clients.clear();
}
}
The Durable Object gives us a single coordination point per video id across all of Cloudflare's edge, which is exactly the primitive this pattern needs. Ten thousand viewers of the same clip become ten thousand cheap edge connections and one origin stream. Our PHP box stops caring about the crowd size entirely — it is only ever talking to a handful of Workers.
What we measured
After the switch, on the next comparable viral event:
- Origin counter requests dropped from a sustained ~20,000 rps of polling to a near-flat line — one origin stream per hot video, plus the ordinary increment
POSTs. - LiteSpeed worker utilisation on the counter path fell by well over 95 percent, because idle streams send nothing but a heartbeat every fifteen seconds.
- The counter updates felt instant instead of laggy, since a real change now pushes immediately rather than waiting up to two seconds for the next poll.
- We collected exactly as much personal data as before, which is to say none.
Conclusion
Server-Sent Events are the right tool for any value the server pushes and the client only displays — live counts, prices, scores, progress bars. They are less capable than WebSockets on purpose, and that smaller surface is what makes them cheap: plain HTTP, automatic reconnection, and no protocol upgrade to babysit. Pair them with SQLite WAL so reads never block the increment path, remember X-Accel-Buffering: no so LiteSpeed actually streams, subscribe only to what is on screen, and put a Durable Object in front so a viral spike collapses into one origin stream per video. That combination turned the widget that took our site down into the cheapest thing on the page.
If you take one thing from this: a counter that has not changed should cost you nothing to serve. Polling never gets there. Streaming does.
Top comments (0)