A view counter that lied to our users for sixty seconds
At ViralVidVault we track viral videos across Europe, and the single most-watched number on any watch page is the view count. During a trend spike a clip can pick up tens of thousands of views in a couple of minutes. For a long time our counter was a lie: the page rendered a number on load, and JavaScript re-polled /api/views?v=... every fifteen seconds to freshen it. Two things went wrong. Users watching a clip while it was blowing up saw a frozen number that jumped in ugly fifteen-second steps. And every one of those polls was a cache-miss request that punched straight through Cloudflare to our LiteSpeed origin — a lot of pointless round trips for a six-byte integer, multiplied by every open tab.
Server-Sent Events fixed both problems. This is the design we shipped and still run: a PHP 8.4 streaming endpoint backed by SQLite in WAL mode, kept unbuffered through LiteSpeed, fanned out at the edge with a Cloudflare Worker, and GDPR-clean because the stream carries nothing but aggregate integers. If you want to watch it tick in real time it is live on every watch page at ViralVidVault. This post is the whole thing, warts included.
Why SSE and not WebSockets or polling
The instinct for anything "live" is to reach for WebSockets. For a one-way count stream that is the wrong tool. Here is how I reasoned about it:
- The data flow is one-directional. The browser never sends anything after the initial GET. It only wants to receive a number. WebSockets give you a bidirectional channel you will never use in one direction, plus an upgrade handshake, plus a framing protocol, plus your own reconnection and heartbeat logic.
-
SSE is just an HTTP response that never ends. It rides on the same request pipeline as everything else. Cloudflare, LiteSpeed, and my existing PHP router all understand it without special configuration. No
Upgrade: websocket, no sticky-session gymnastics. -
Reconnection is built into the browser.
EventSourcereconnects automatically on drop and replays theLast-Event-IDheader so you can resume. With WebSockets you write that yourself, every time. - Polling is simple but wasteful. A fifteen-second poll wastes bandwidth when nothing changed and is stale when something did. SSE pushes only on change and pushes it immediately.
The one real cost of SSE is that each open stream holds a connection. On a classic prefork PHP setup that means one worker per viewer, which does not scale. Most of this article is about not paying that cost — by capping connection lifetime, keeping the socket cheap, and coalescing thousands of viewers into a handful of origin connections at the edge.
SQLite WAL as the source of truth
Our view counts live in a small SQLite database that the ingest cron and the watch-page beacon both write to. The reason SSE is even viable on SQLite is Write-Ahead Logging. In the default rollback-journal mode a writer blocks readers and vice versa, which would be fatal when you have hundreds of long-lived reader connections polling the same table. In WAL mode readers never block the writer and the writer never blocks readers — exactly the concurrency shape a fan of SSE streams needs.
The schema is deliberately boring. One row per video, updated in place:
CREATE TABLE IF NOT EXISTS video_counts (
video_id TEXT PRIMARY KEY,
views INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0
) WITHOUT ROWID;
-- WAL is a per-database setting, persisted in the file header.
-- Set it once at creation; every later connection inherits it.
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; -- safe with WAL, far fewer fsyncs
The ingest side does an ordinary upsert (INSERT ... ON CONFLICT(video_id) DO UPDATE SET views = ...). The SSE readers only ever run a single indexed SELECT views against the primary key. That query is a handful of microseconds and, thanks to WAL, contends with nothing. synchronous = NORMAL is the right trade here: a crash can lose the last few writes to a counter, which self-heals on the next ingest, and in exchange you avoid an fsync storm.
The PHP 8.4 streaming endpoint
Here is the endpoint. The important parts are the headers that defeat buffering, the bounded lifetime so a worker is never held forever, and the comment-line heartbeat that keeps intermediaries from reaping an idle socket.
<?php
declare(strict_types=1);
// stream.php — live view-count stream for one video
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-transform');
header('X-Accel-Buffering: no'); // stop LiteSpeed/nginx from buffering us
header('Connection: keep-alive');
$videoId = $_GET['v'] ?? '';
if (!preg_match('/^[A-Za-z0-9_-]{6,20}$/', $videoId)) {
http_response_code(400);
exit;
}
ignore_user_abort(false);
set_time_limit(0);
while (ob_get_level() > 0) { ob_end_flush(); } // no output buffers in the way
$db = new PDO('sqlite:' . __DIR__ . '/data/views.db', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$db->exec('PRAGMA busy_timeout = 2000');
$stmt = $db->prepare('SELECT views FROM video_counts WHERE video_id = ?');
$lastSent = null;
$deadline = time() + 55; // recycle before LiteSpeed's idle cap kicks in
// Honour resume: send retry hint so the browser backs off sensibly
echo "retry: 3000\n\n";
while (time() < $deadline) {
if (connection_aborted()) {
break;
}
$stmt->execute([$videoId]);
$views = $stmt->fetchColumn();
if ($views !== false && (int) $views !== $lastSent) {
$lastSent = (int) $views;
echo 'id: ' . time() . "\n";
echo "event: views\n";
echo 'data: ' . json_encode(['v' => $videoId, 'views' => $lastSent]) . "\n\n";
} else {
echo ": ping\n\n"; // comment line: keeps the socket warm, ignored by client
}
flush();
sleep(2);
}
// ask the client to reconnect immediately and grab a fresh worker
echo "event: cycle\ndata: bye\n\n";
flush();
Four details earn their keep:
-
X-Accel-Buffering: noplus flushing every tick. LiteSpeed and nginx will happily buffer a response and hand it to the client in one lump at the end, which turns a live stream into a sixty-second-late batch. This header disables that per response, and clearing PHP's output buffers ensures nothing pools on our side either. - A fifty-five-second deadline. Never hold a worker forever. LiteSpeed will drop an idle backend connection, and a wedged stream is a leaked worker. We voluntarily end the response just before that limit and tell the client to reconnect. A reconnect is cheap; a leaked worker is not.
-
The
: pingcomment line. When the count has not changed we still write a comment every couple of seconds. Comments are valid SSE the client ignores, and they stop Cloudflare and any proxy from deciding the connection is dead. -
connection_aborted(). The moment the tab closes we stop querying and free the worker. Without this you keep polling SQLite for a browser that left.
This is honestly fine for a few hundred concurrent streams per box. The sleep(2) diff-poll against a WAL primary-key lookup is trivial load. The ceiling is worker count, not database load, which is the next problem to solve.
The browser side is almost nothing
EventSource does the heavy lifting. The only logic worth writing is graceful handling of our deliberate reconnect event and backing off when the tab is hidden so we do not hold a stream for a video nobody is looking at.
const el = document.querySelector('[data-views]');
const videoId = el.dataset.views;
let es;
function connect() {
es = new EventSource(`/stream.php?v=${encodeURIComponent(videoId)}`);
es.addEventListener('views', (e) => {
const { views } = JSON.parse(e.data);
el.textContent = new Intl.NumberFormat('en-GB').format(views);
el.classList.add('pulse');
setTimeout(() => el.classList.remove('pulse'), 400);
});
// our worker recycled itself; grab a fresh one right away
es.addEventListener('cycle', () => {
es.close();
connect();
});
es.onerror = () => {
// EventSource retries on its own using the retry: hint,
// but if the tab is hidden, stop holding a stream nobody sees
if (document.hidden) {
es.close();
}
};
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden && (!es || es.readyState === EventSource.CLOSED)) {
connect();
}
});
if (!document.hidden) connect();
No reconnection loop, no exponential backoff library, no heartbeat parser. The browser reconnects on network drops using the retry: 3000 hint we sent, resumes with Last-Event-ID if we cared to key on it, and Intl.NumberFormat('en-GB') formats the number the way a European audience expects — 1,234,567, not a raw integer. The visibilitychange handler is the cheapest scaling win available: a huge share of open tabs are backgrounded, and none of them need a live socket.
Coalescing thousands of viewers into one origin connection
The PHP endpoint gives every viewer their own worker and their own SQLite poll loop. When a video actually goes viral — the exact moment the live counter matters most — that is five thousand workers you do not have. The fix is to never let those connections reach the origin as distinct streams.
The pattern that scales is fan-out: one process holds a single subscription to the source of truth and broadcasts each change to every connected client. I keep a small Go service in front of the counter store for the high-traffic path. It reads changes once and pushes to N subscribers, with a bounded per-client buffer so one slow reader cannot stall the broadcast.
package main
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
type Count struct {
VideoID string `json:"v"`
Views int64 `json:"views"`
}
type Hub struct {
mu sync.RWMutex
subs map[string]map[chan Count]struct{} // videoID -> subscribers
}
func newHub() *Hub {
return &Hub{subs: make(map[string]map[chan Count]struct{})}
}
func (h *Hub) subscribe(id string) chan Count {
ch := make(chan Count, 4)
h.mu.Lock()
if h.subs[id] == nil {
h.subs[id] = make(map[chan Count]struct{})
}
h.subs[id][ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *Hub) unsubscribe(id string, ch chan Count) {
h.mu.Lock()
if set := h.subs[id]; set != nil {
delete(set, ch)
close(ch)
if len(set) == 0 {
delete(h.subs, id)
}
}
h.mu.Unlock()
}
// publish is called once per change by the ingest side
func (h *Hub) publish(c Count) {
h.mu.RLock()
for ch := range h.subs[c.VideoID] {
select {
case ch <- c: // delivered
default: // slow client, drop; the next update corrects it
}
}
h.mu.RUnlock()
}
func (h *Hub) stream(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("v")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
ch := h.subscribe(id)
defer h.unsubscribe(id, ch)
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done(): // client left
return
case c := <-ch:
b, _ := json.Marshal(c)
fmt.Fprintf(w, "event: views\ndata: %s\n\n", b)
flusher.Flush()
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
}
}
}
The select with a default on the send is the whole trick: if a client's buffered channel is full, we drop the update rather than block the broadcaster. For a monotonic counter that is completely safe — the next tick carries the correct absolute value, so a dropped frame just means one viewer skipped a number, not that state diverged. That single line is the difference between a broadcaster that stays real-time under load and one that convoys behind its slowest reader.
Pushing the fan-out to the edge with Cloudflare Workers
Even a lean Go hub still terminates every viewer's TCP connection somewhere. Since we already sit behind Cloudflare, the cleaner move for genuinely viral clips is to fan out at the edge, so that per-colo we hold one origin connection per video no matter how many people in that region are watching. A Durable Object gives you exactly one coordinating instance per key, which is the natural home for the subscriber list.
export default {
async fetch(request, env) {
const url = new URL(request.url);
const videoId = url.searchParams.get('v');
// Only intercept the SSE path; everything else follows normal caching
if (!url.pathname.endsWith('/stream.php') || !videoId) {
return fetch(request);
}
// One Durable Object per video: it keeps a single upstream SSE
// connection and rebroadcasts to every viewer in this colo.
// 5,000 viewers no longer means 5,000 origin sockets.
const id = env.FANOUT.idFromName(videoId);
const stub = env.FANOUT.get(id);
return stub.fetch(request);
},
};
The Durable Object opens one upstream EventSource-style fetch to the PHP or Go origin, holds the client ReadableStreams, and writes each parsed update to all of them. The origin now serves a bounded number of streams — roughly one per video per active colo — instead of one per human. That is the number that keeps LiteSpeed comfortable when a clip trends across the whole continent at once.
The GDPR angle, because we are a European product
A quiet advantage of streaming aggregate counts is that the stream endpoint touches no personal data at all. The payload is {"v":"abc123","views":48213} — a public video id and a public integer. There are no cookies on the stream.php request, no user identifier, no fingerprinting, nothing that ties the connection to a person beyond the IP that HTTP inherently exposes. That keeps the whole feature outside consent-banner scope: it is not analytics about the user, it is a public metric about the content.
The discipline that makes this true:
- No cookies on the stream route. We never set or read a session cookie there, so there is nothing to gate behind consent.
-
Counting happens server-side. The increment beacon that feeds
video_countsis separate, minimal, and aggregates immediately — we store a per-video total, not a per-visitor event log. - The stream is read-only and anonymous. It reveals a number the whole world can see anyway. There is no lawful-basis question because there is no personal data in flight.
Getting the boundary right means the live counter never becomes a compliance liability, which for a European-market product is as important as the millisecond latency.
What I would tell you before you build this
Server-Sent Events are unglamorous and that is precisely why they are the right answer for a live counter. You get automatic reconnection, a plain HTTP transport your whole stack already speaks, and a payload small enough that the interesting engineering is entirely about connection economics, not protocol design.
The checklist that actually matters:
- Turn off buffering explicitly (
X-Accel-Buffering: no) and flush every write, or you ship a batch pretending to be a stream. - Bound the lifetime of each connection and recycle it deliberately, rather than letting workers pile up.
- Send heartbeat comments so intermediaries do not reap idle sockets.
- Diff before you send — push on change, ping otherwise.
- Fan out before viewer count multiplies origin connections; drop frames for slow clients on a monotonic counter without a second thought.
- Keep the payload aggregate and cookie-free, and the privacy story writes itself.
That combination has carried our live view counts through several full-continent trend spikes without the origin breaking a sweat, and users finally see the number climb the way it actually climbs.
Top comments (0)