DEV Community

ahmet gedik
ahmet gedik

Posted on

Streaming Live Video View Counts With Server-Sent Events in PHP

The counter that lied to everyone

On a busy watch page the view count is the first number a visitor trusts and the last one we ever get right. At TopVideoHub a trending K-drama clip can pick up 40,000 views in ten minutes across our Japanese, Korean, and Taiwanese edge traffic. If PHP renders 61,204 into the HTML and the real figure is already 71,900 by the time a reader in Osaka finishes the intro, the page feels dead.

Our first instinct — every team's instinct — was to poll. A little setInterval, a JSON endpoint, fetch every five seconds. It worked in staging with three tabs open. In production it was a slow-motion self-DDoS: 8,000 concurrent watch pages each firing every five seconds is 1,600 requests per second landing on a SQLite file that also serves search.

This is how we replaced that polling storm with one long-lived stream per client using Server-Sent Events (SSE), how we made it survive LiteSpeed and Cloudflare, and — the part most SSE tutorials skip — how we stopped it from turning a connection storm into a database storm.

Why SSE and not WebSockets or polling

A view count is a one-directional problem. The server has data; the browser only consumes it and never sends anything back on the same channel. That asymmetry is precisely what SSE was built for, and it buys three things WebSockets don't:

  • It rides plain HTTP. No Upgrade handshake, no separate proxy config, no special Cloudflare toggle. It's an ordinary GET whose text/event-stream body simply never ends.
  • Reconnection is built into the browser. EventSource reconnects on drop and replays a Last-Event-ID header for you. With WebSockets you hand-roll that every time.
  • It debugs like HTTP. You can curl an SSE endpoint and watch events scroll past. Try that with a raw WebSocket frame.

The usual objection is the ~6-connections-per-host cap on HTTP/1.1. Over HTTP/2 — which everything behind Cloudflare already speaks — that becomes ~100 streams multiplexed onto a single TCP connection, so for our traffic the objection is mostly dead. Polling, by contrast, scales its cost with clients × frequency no matter what you cache. SSE scales with clients, and pushes only when something actually changes.

The wire format in thirty seconds

An SSE stream is UTF-8 text. Each message is a block of field: value lines terminated by a blank line. Four fields matter:

  • data: — the payload (repeat the line for multi-line data).
  • event: — a named type your client listens for; defaults to message.
  • id: — an opaque cursor the browser echoes back as Last-Event-ID on reconnect.
  • retry: — reconnect delay in milliseconds.

A line starting with : is a comment. We abuse comments as heartbeats to keep intermediaries from closing an idle connection. That's the entire protocol — no framing, no length prefixes, no binary. UTF-8 is a happy accident for us: our payloads are pure ASCII digits, but the same channel would carry CJK titles unmodified if we ever streamed them.

The PHP endpoint

Here is the watch-page stream. The subtle work is all in the first ten lines: LiteSpeed, like Nginx, will happily buffer PHP output and defeat the whole point, so we dismantle every buffer before writing a byte.

<?php
declare(strict_types=1);

// Never let LiteSpeed or PHP buffer a stream.
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', '0');
while (ob_get_level() > 0) {
    ob_end_flush();
}

header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // tell proxies not to buffer

// The video id we are watching, validated.
$videoId = preg_replace('/[^A-Za-z0-9_-]/', '', $_GET['v'] ?? '');
if ($videoId === '') {
    http_response_code(400);
    exit;
}

// Resume support: the browser sends the last id it saw.
$lastSeen = (int) ($_SERVER['HTTP_LAST_EVENT_ID'] ?? 0);

$snapshot    = new ViewSnapshot(__DIR__ . '/data/backlink.db');
$maxLifetime = 55; // seconds, then let EventSource reconnect
$start       = time();
$lastCount   = -1;

while (!connection_aborted() && (time() - $start) < $maxLifetime) {
    $count = $snapshot->countFor($videoId);

    if ($count !== $lastCount) {
        $eventId = time();
        echo "id: {$eventId}\n";
        echo "event: views\n";
        echo 'data: ' . json_encode(['v' => $videoId, 'count' => $count]) . "\n\n";
        @ob_flush();
        flush();
        $lastCount = $count;
    } else {
        echo ": ping\n\n"; // heartbeat keeps the connection and proxies awake
        @ob_flush();
        flush();
    }

    sleep(2);
}
Enter fullscreen mode Exit fullscreen mode

Three details earn their keep. X-Accel-Buffering: no tells LiteSpeed and any reverse proxy to stop buffering this response. The 55-second $maxLifetime is deliberate: it's under Cloudflare's 100-second idle ceiling and, more importantly, it lets the PHP worker cycle instead of being pinned forever — I'll come back to why that number is the whole ballgame on shared hosting. And we only emit a views event when the count actually changed; otherwise we send a : ping comment so the socket and every proxy in the path stay warm.

The trap: one connection storm becomes a query storm

Naively, that loop runs SELECT view_count ... every two seconds per connection. Eight thousand streams at a two-second interval is 4,000 queries per second against SQLite — we'd have moved the stampede from the front door to the basement.

The fix is to collapse all readers of the same video onto one query per interval with a tiny shared cache. APCu is process-shared across every PHP worker on the box, so a two-second TTL means each distinct video hits SQLite at most once every two seconds no matter how many streams are watching it.

<?php
declare(strict_types=1);

final class ViewSnapshot
{
    private const TTL = 2; // seconds of staleness we tolerate

    public function __construct(private string $dbPath) {}

    public function countFor(string $videoId): int
    {
        $key = "views:{$videoId}";

        // Shared across every PHP worker on the box via APCu.
        $cached = apcu_fetch($key, $ok);
        if ($ok) {
            return (int) $cached;
        }

        $stmt = $this->pdo()->prepare(
            'SELECT view_count FROM videos WHERE video_id = :v LIMIT 1'
        );
        $stmt->execute([':v' => $videoId]);
        $count = (int) ($stmt->fetchColumn() ?: 0);

        apcu_store($key, $count, self::TTL);
        return $count;
    }

    private function pdo(): \PDO
    {
        static $pdo = null;
        if ($pdo instanceof \PDO) {
            return $pdo;
        }
        $pdo = new \PDO('sqlite:' . $this->dbPath, null, null, [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
        ]);
        // Read-heavy: WAL lets readers and the ingest writer coexist.
        $pdo->exec('PRAGMA journal_mode = WAL');
        $pdo->exec('PRAGMA busy_timeout = 2000');
        return $pdo;
    }
}
Enter fullscreen mode Exit fullscreen mode

With this in place the database load is bounded by the number of distinct trending videos being watched, not by viewers. On our numbers that's a few hundred rows refreshed every couple of seconds — a rounding error next to the search workload. WAL mode matters here too: it lets the cron job that ingests new view deltas keep writing while thousands of readers pull counts, without writer-blocks-reader contention.

The browser side

EventSource does the heavy lifting. We format with Intl.NumberFormat so a Japanese visitor sees 71,900 the way they expect, animate a tick, and — critically for connection budget — close the stream when the tab is hidden.

function liveViews(videoId, el) {
  const url = `/stream.php?v=${encodeURIComponent(videoId)}`;
  const fmt = new Intl.NumberFormat(navigator.language);
  const es  = new EventSource(url);

  es.addEventListener('views', (e) => {
    const { count } = JSON.parse(e.data);
    el.textContent = fmt.format(count);
    el.classList.add('tick');
    setTimeout(() => el.classList.remove('tick'), 300);
  });

  es.onerror = () => {
    // EventSource retries on its own; only re-arm after a hard close.
    if (es.readyState === EventSource.CLOSED) {
      setTimeout(() => liveViews(videoId, el), 5000);
    }
  };

  // Stop streaming when the tab is hidden to save a connection.
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      es.close();
    } else if (es.readyState === EventSource.CLOSED) {
      liveViews(videoId, el);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

That visibilitychange handler matters more than it looks. A user with fifteen background tabs would otherwise hold fifteen live streams — and on LiteSpeed, as we're about to see, streams are the scarce resource. Closing hidden tabs' connections gave us back roughly a third of our concurrent-stream count for free.

LiteSpeed and Cloudflare, where SSE goes to die

Everything above works on localhost and then falls over in production, because two layers between PHP and the browser want to buffer. Here's the checklist that cost us an afternoon:

  • LiteSpeed output buffering. Disable zlib.output_compression and output_buffering for the endpoint, and drain ob_* before streaming. Without this LiteSpeed holds your bytes and delivers them in one lump when the script ends — a stream with the streaming removed.
  • A PHP worker per stream. This is the big one. An SSE connection pins one LSAPI worker for its whole lifetime. If your plan has 100 workers, you can serve ~100 concurrent streams, full stop. That's why $maxLifetime is 55 seconds and the sleep is 2, not 0: short-lived connections recycle workers, and EventSource transparently reconnects. Treat worker count as your hard concurrency ceiling and monitor it.
  • Cloudflare no-transform. Cloudflare's compression and Rocket Loader can buffer or rewrite the body. Send Cache-Control: no-cache, no-transform and add a cache-bypass rule for the stream path so it's never treated as a cacheable asset.
  • Cloudflare idle timeout. Free and Pro plans drop connections idle for ~100 seconds. The heartbeat plus the sub-55s lifetime keep you comfortably inside it.
  • Content-Type is load-bearing. It must be exactly text/event-stream. Get it wrong and EventSource refuses the response with an opaque error and no body to inspect.

When you outgrow PHP: an in-memory hub in Go

The PHP + APCu approach is the right first move — it's a single file, it runs on the shared LiteSpeed box we already pay for, and it fits our watch-page traffic. But the worker-per-stream ceiling is real, and view increments actually arrive from many regional ingest workers at once. When concurrency climbs past roughly half your worker count, the answer is a small sidecar that holds the authoritative counter in memory and pushes only on change — goroutines cost kilobytes, not workers.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "sync"
    "time"
)

type Hub struct {
    mu     sync.RWMutex
    counts map[string]int64
    subs   map[string]map[chan int64]struct{}
}

func NewHub() *Hub {
    return &Hub{
        counts: make(map[string]int64),
        subs:   make(map[string]map[chan int64]struct{}),
    }
}

// Increment is called by the regional ingest workers.
func (h *Hub) Increment(id string, delta int64) {
    h.mu.Lock()
    h.counts[id] += delta
    v := h.counts[id]
    for ch := range h.subs[id] {
        select {
        case ch <- v:
        default: // drop if the client is slow; the next change catches it up
        }
    }
    h.mu.Unlock()
}

func (h *Hub) stream(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("v")
    flusher, ok := w.(http.Flusher)
    if !ok || id == "" {
        http.Error(w, "unsupported", http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache, no-transform")
    w.Header().Set("X-Accel-Buffering", "no")

    ch := make(chan int64, 4)
    h.mu.Lock()
    if h.subs[id] == nil {
        h.subs[id] = make(map[chan int64]struct{})
    }
    h.subs[id][ch] = struct{}{}
    h.mu.Unlock()

    defer func() {
        h.mu.Lock()
        delete(h.subs[id], ch)
        h.mu.Unlock()
    }()

    ticker := time.NewTicker(15 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-r.Context().Done():
            return
        case v := <-ch:
            payload, _ := json.Marshal(map[string]any{"v": id, "count": v})
            fmt.Fprintf(w, "event: views\ndata: %s\n\n", payload)
            flusher.Flush()
        case <-ticker.C:
            fmt.Fprint(w, ": ping\n\n")
            flusher.Flush()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things make this scale where the PHP version can't. First, it's push, not poll: a client's goroutine sleeps until Increment fans a new value to its channel, so idle videos cost nothing. Second, goroutines are cheap enough that 8,000 concurrent streams is unremarkable — the ceiling becomes file descriptors and bandwidth, not a fixed worker pool. The Go service still flushes aggregated counts to SQLite on an interval so the rest of the PHP app — search, sitemaps, the initial HTML render — reads the same source of truth.

Reconnection and backfill

Because we set an id: on every event, a browser that drops and reconnects sends Last-Event-ID, which we read as $lastSeen at the top of the PHP endpoint. For a monotonic counter you rarely need to replay history — the current value is all anyone wants — but the hook is there if you ever stream a delta feed where a missed event matters. For view counts, sending the freshest absolute number on every (re)connect is both simpler and correct.

What we'd tell our past selves

  • Start with SSE, not WebSockets, for anything read-only. Half the code, free reconnection, curl-debuggable.
  • Cache between the stream and the database. The connection count and the query count are two different problems; solve the second with a short shared TTL or you've just relocated the stampede.
  • On LiteSpeed, count workers, not connections. Keep stream lifetimes short and let the browser reconnect. Know your ceiling before your users find it.
  • Move to an in-memory push hub only when the numbers demand it. The PHP version is genuinely fine until it isn't, and premature Go is its own kind of debt.

Live view counts turned out to be a small feature with a deceptively deep tail — the interesting engineering was never the stream itself but everything that wanted to buffer, block, or duplicate it on the way to the reader. SSE gave us a counter that finally tells the truth, at a fraction of the cost of the polling we started with.

Top comments (0)