DEV Community

ahmet gedik
ahmet gedik

Posted on

Streaming Live Video View Counts with Server-Sent Events and PHP 8.4

Watch pages on TopVideoHub list the live view counts for trending YouTube videos across nine Asia-Pacific regions. The number is the third thing your eye lands on after the thumbnail and the title, and when it sits frozen for ninety seconds while a viewer is on the page, the page feels stale — even if every other piece of content is fresh. We tried polling. We tried just letting the number update on next page load. Neither felt right for a video discovery site where users hop between five and ten pages per session. This is the story of how I replaced our 30-second poll loop with a Server-Sent Events stream on PHP 8.4 behind LiteSpeed and Cloudflare, and what broke along the way. TopVideoHub aggregates trending videos across nine APAC regions, so the constraints below are real production numbers, not a hello-world demo.

Why Not WebSockets

Every architecture post on real-time updates jumps straight to WebSockets. For view counts, that is overkill. The traffic is strictly one-way: the server pushes a number, the browser displays it. There is no client-to-server message, ever. Bringing in a duplex protocol means:

  • A separate WebSocket handler process (or Swoole / ReactPHP). LSAPI does not speak WebSocket.
  • A different authentication path. Cookies work, but session reuse across HTTP and WS is annoying.
  • Cloudflare's WebSocket pass-through is fine, but their analytics, caching rules, and Page Rules treat WS connections as a separate beast.
  • Reconnection logic you write yourself. With SSE, the browser handles it.

Server-Sent Events solve the actual problem with plain HTTP. The browser opens a text/event-stream response, the server writes lines as they happen, and the browser dispatches MessageEvent objects. Native reconnection with Last-Event-ID. Native back-pressure via TCP. Works through every corporate proxy that allows HTTP. The only downside is the per-domain connection limit — six in HTTP/1.1, irrelevant in HTTP/2, which Cloudflare gives you for free.

The relevant trade-off: WebSockets are right when you need duplex, low-latency, binary frames. View counts are 30 bytes of JSON every couple of seconds. SSE wins.

The Concrete Problem

Here is what the page needs:

  • 12 video cards, each with a view-count number.
  • A watch page with one prominent count for the active video.
  • Counts that tick visibly when they change (small green flash).
  • Updates within two or three seconds of when YouTube's API reflects them.

And the constraints:

  • PHP 8.4 on LSAPI workers, not long-running daemons.
  • YouTube Data API quota: 10,000 units/day. videos.list?part=statistics costs 1 unit per request regardless of batch size up to 50 IDs.
  • LiteSpeed default config buffers small responses.
  • Cloudflare in front of every site.
  • Origin is one cheap shared host per site.

That last constraint is the kicker. I cannot afford a separate Node or Go process for SSE. The stream has to come out of a PHP worker.

The SSE Endpoint in PHP 8.4

A few things have to be true for SSE to work on LSAPI:

  1. Output buffering must be defeated, including LiteSpeed's internal buffering.
  2. The response must declare Content-Type: text/event-stream.
  3. The worker must flush after every write and survive long enough to send multiple events.
  4. connection_aborted() must be checked, because once the client leaves there is no reason to keep hitting the database.

Here is the endpoint we run in production, lightly anonymized:

<?php
declare(strict_types=1);

// public/api/views/stream.php

const TICK_MS = 1500;
const MAX_DURATION_S = 90;
const MAX_IDS = 50;

@set_time_limit(MAX_DURATION_S + 5);
ignore_user_abort(false);

header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('X-Accel-Buffering: no');
header('Connection: keep-alive');

while (ob_get_level() > 0) { ob_end_clean(); }
ob_implicit_flush(true);

$raw = $_GET['ids'] ?? '';
$ids = array_values(array_unique(array_filter(
    array_map('trim', explode(',', $raw)),
    fn(string $s): bool => (bool) preg_match('/^[A-Za-z0-9_-]{6,32}$/', $s)
)));
$ids = array_slice($ids, 0, MAX_IDS);

if (!$ids) {
    http_response_code(400);
    echo "event: error\n";
    echo 'data: ' . json_encode(['reason' => 'bad_ids']) . "\n\n";
    exit;
}

$lastEventId = (int) ($_SERVER['HTTP_LAST_EVENT_ID'] ?? '0');
$seq = $lastEventId;

$db = new PDO('sqlite:/var/www/data/views.sqlite', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec('PRAGMA query_only = 1');

$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare(
    "SELECT video_id, view_count FROM video_views WHERE video_id IN ($placeholders)"
);

function emit(int $id, string $event, array $data): void {
    echo "id: $id\n";
    echo "event: $event\n";
    echo 'data: ' . json_encode(
        $data,
        JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    ) . "\n\n";
    @flush();
}

$sent = [];
$start = microtime(true);

// Initial snapshot: guarantee one event in the first second.
$stmt->execute($ids);
$snapshot = [];
foreach ($stmt as $row) {
    $vid = $row['video_id'];
    $cnt = (int) $row['view_count'];
    $snapshot[$vid] = $cnt;
    $sent[$vid]     = $cnt;
}
emit(++$seq, 'snapshot', $snapshot);

while ((microtime(true) - $start) < MAX_DURATION_S) {
    if (connection_aborted()) { break; }

    usleep(TICK_MS * 1000);

    $stmt->execute($ids);
    $diff = [];
    foreach ($stmt as $row) {
        $vid = $row['video_id'];
        $cnt = (int) $row['view_count'];
        if (!isset($sent[$vid]) || $sent[$vid] !== $cnt) {
            $diff[$vid] = $cnt;
            $sent[$vid] = $cnt;
        }
    }

    if ($diff) {
        emit(++$seq, 'views', $diff);
    } else {
        echo ': keep-alive ' . time() . "\n\n";
        @flush();
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details worth calling out:

  • We validate ids against a strict regex. YouTube IDs are 11 chars today, but I keep the bound loose for the next platform. Anything that fails the regex is dropped silently.
  • The initial snapshot event is sent immediately, before the loop starts. Without it, the page would sit blank for TICK_MS waiting for the first delta.
  • PRAGMA query_only = 1 is cheap insurance against an accidental write from this read-only path.
  • The Last-Event-ID header lets reconnects continue numbering from where they left off. We do not use it to resume state because counts are absolute, not deltas, but the IDs are monotonic for debugging.
  • JSON_UNESCAPED_UNICODE matters more for the title-streaming variant of this endpoint, where Japanese and Korean characters would otherwise inflate three times as \uXXXX escapes.

The Aggregator

The SSE endpoint reads from views.sqlite. Something has to write to it. On our setup that something is a Python aggregator running under systemd on one host; the other hosts pull the resulting SQLite file via rsync because the YouTube API quota is shared across all four sites anyway. Quota is the binding constraint, not write throughput.

# tools/views_aggregator.py
import os, sqlite3, time, signal, sys
from typing import Iterable
import httpx

API_KEY = os.environ['YT_API_KEY']
DB_PATH = '/var/www/data/views.sqlite'
BATCH        = 50    # YouTube API hard limit per videos.list call
HOT_WINDOW_S = 600   # only stream videos watched in the last 10 min
INTERVAL_S   = 5

_stop = False

def _on_term(*_):
    global _stop
    _stop = True

def chunked(seq: list[str], n: int) -> Iterable[list[str]]:
    for i in range(0, len(seq), n):
        yield seq[i:i + n]

def hot_video_ids(db: sqlite3.Connection) -> list[str]:
    cur = db.execute(
        "SELECT video_id FROM video_views "
        "WHERE last_watched_at > strftime('%s','now') - ? "
        "ORDER BY last_watched_at DESC LIMIT 500",
        (HOT_WINDOW_S,),
    )
    return [r[0] for r in cur.fetchall()]

def fetch_counts(client: httpx.Client, ids: list[str]) -> dict[str, int]:
    r = client.get(
        'https://www.googleapis.com/youtube/v3/videos',
        params={'part': 'statistics', 'id': ','.join(ids), 'key': API_KEY},
        timeout=10.0,
    )
    r.raise_for_status()
    out: dict[str, int] = {}
    for item in r.json().get('items', []):
        stats = item.get('statistics', {})
        if 'viewCount' in stats:
            out[item['id']] = int(stats['viewCount'])
    return out

def apply(db: sqlite3.Connection, counts: dict[str, int]) -> int:
    if not counts:
        return 0
    rows = [(c, vid, c) for vid, c in counts.items()]
    cur = db.executemany(
        "UPDATE video_views "
        "   SET view_count = ?, updated_at = strftime('%s','now') "
        " WHERE video_id = ? AND view_count != ?",
        rows,
    )
    db.commit()
    return cur.rowcount or 0

def main() -> None:
    signal.signal(signal.SIGTERM, _on_term)
    signal.signal(signal.SIGINT,  _on_term)

    db = sqlite3.connect(DB_PATH, isolation_level=None)
    db.execute('PRAGMA journal_mode=WAL')
    db.execute('PRAGMA synchronous=NORMAL')
    db.execute('PRAGMA busy_timeout=5000')

    with httpx.Client(http2=True) as client:
        while not _stop:
            t0 = time.monotonic()
            try:
                ids = hot_video_ids(db)
                changed = 0
                for batch in chunked(ids, BATCH):
                    counts = fetch_counts(client, batch)
                    changed += apply(db, counts)
                if changed:
                    elapsed = time.monotonic() - t0
                    print(f'[agg] {changed} changes in {elapsed:.2f}s '
                          f'({len(ids)} ids)', flush=True)
            except httpx.HTTPError as e:
                print(f'[agg] http {e!r}', file=sys.stderr, flush=True)
            time.sleep(max(0.5, INTERVAL_S - (time.monotonic() - t0)))

if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

The WHERE view_count != ? guard in apply() ensures we only mark rows as changed when the YouTube number genuinely moved. Without it the SSE endpoint would still suppress duplicates via its $sent[$vid] !== $cnt check, but we would be spending CPU on the database side for nothing.

Math check: 500 IDs at 50 per batch is 10 API requests every 5 seconds, which is 172,800 requests/day. That is way over the 10,000/day quota. In production we run this at 5-second intervals only for the hottest 50 videos (1 batch, 17,280/day, still over, so we drop to 30-second intervals) and 60-second intervals for the long tail. The constants in the code above need tuning per your quota; that is exactly what we did.

LiteSpeed and Cloudflare Quirks

This took the longest to debug.

LiteSpeed buffering. LSAPI buffers responses by default for performance. SSE requires the opposite. The ob_implicit_flush(true) plus @flush() after every write usually works, but on one of our hosts we also had to add to .htaccess:

<IfModule LiteSpeed>
    <FilesMatch "^stream\.php$">
        SetEnv noabort 1
        SetEnv no-gzip 1
    </FilesMatch>
</IfModule>
Enter fullscreen mode Exit fullscreen mode

no-gzip is essential. If LiteSpeed (or any upstream) is gzipping the SSE stream the browser will not emit events until the gzip window flushes, which can be tens of seconds on a low-traffic stream.

Cloudflare buffering. Cloudflare buffers up to roughly 512KB of response before forwarding. For SSE this is fine because each event is tiny, but if you go more than about 100 seconds without sending anything, the connection gets dropped. Their idle timeout varies but lives in that neighborhood. Hence the heartbeat comment line : keep-alive <ts> every tick.

Cloudflare compression. Cloudflare does NOT compress text/event-stream by default; it sees the Content-Type and skips its Brotli pass. Do not try to be clever and force it, you will just hit the same flush problem as with gzip.

Page Rules. Make sure the SSE path bypasses any Cache Everything page rule:

  • URL match: *example.com/api/views/stream*
  • Setting: Cache Level set to Bypass.
  • Setting: Disable Performance (turns off Rocket Loader for that path; it tries to defer-execute the EventSource client and the result is delightful to debug).

The Frontend

The browser side is short. The trick is treating it as fire-and-forget with managed reconnection:

// public/assets/views-stream.js
(() => {
  const targets = document.querySelectorAll('[data-views-for]');
  if (!targets.length) return;

  const ids = [...new Set(
    [...targets].map(el => el.dataset.viewsFor).filter(Boolean)
  )].slice(0, 50);
  if (!ids.length) return;

  const url = '/api/views/stream?ids=' + encodeURIComponent(ids.join(','));
  let source  = null;
  let backoff = 1000;
  let visible = !document.hidden;

  function open() {
    source = new EventSource(url);
    source.addEventListener('snapshot', e => paint(e, false));
    source.addEventListener('views',    e => paint(e, true));
    source.addEventListener('open',     ()  => { backoff = 1000; });
    source.addEventListener('error',    ()  => {
      source.close();
      source = null;
      if (!visible) return;
      setTimeout(open, backoff);
      backoff = Math.min(backoff * 2, 30000);
    });
  }

  function paint(e, animate) {
    let diff;
    try { diff = JSON.parse(e.data); } catch { return; }
    for (const [id, n] of Object.entries(diff)) {
      const el = document.querySelector(`[data-views-for="${CSS.escape(id)}"]`);
      if (!el) continue;
      const prev = +(el.dataset.raw || 0);
      el.dataset.raw   = String(n);
      el.textContent   = fmt(n);
      if (animate && n !== prev) {
        el.classList.add('tick');
        setTimeout(() => el.classList.remove('tick'), 400);
      }
    }
  }

  function fmt(n) {
    if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
    if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
    if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
    return String(n);
  }

  document.addEventListener('visibilitychange', () => {
    visible = !document.hidden;
    if (visible && !source) { backoff = 1000; open(); }
    if (!visible && source) { source.close(); source = null; }
  });

  open();
})();
Enter fullscreen mode Exit fullscreen mode

The visibilitychange handling matters more than you would think. A backgrounded tab holding an SSE connection costs you a PHP worker for nothing. Close on hidden, reopen on visible. The 90-second server-side cap means even forgotten foreground tabs cycle cleanly on their own.

Capacity Math

The honest reason most PHP shops avoid SSE is fear of running out of workers. Let us do the numbers for one of our hosts:

  • LiteSpeed MaxConnections: 2000.
  • LSAPI worker children: 100 (configurable).
  • Average concurrent visitors on a single watch page: about 30 during APAC prime time.
  • Each SSE connection holds 1 LSAPI worker for up to 90 seconds.
  • 30 viewers x 1 worker each = 30 workers occupied for SSE.
  • Remaining 70 workers for normal page loads, which take roughly 80ms each.

Even at 200 concurrent viewers (a number we hit during a Korean drama trailer drop) we are at 200/100 = 2x overcommit, which LiteSpeed handles by queueing — but only if your normal page-load workers are returning fast. Watch the LiteSpeed admin: any climb in BusyServers past 80% is the signal to either widen the worker pool or shorten MAX_DURATION_S on the SSE endpoint.

The right answer for very high concurrency is to terminate SSE in a separate, lightweight runtime (Go, Bun, OpenResty) that fans out from the same SQLite via mmap reads. We have not needed it yet.

CJK Specifics That Surprised Us

Because the same endpoint serves an enriched variant with localized titles, two things bit us:

  • The default json_encode escapes non-ASCII to \uXXXX. A Japanese title that is 18 visible characters becomes 108 bytes of escapes. With JSON_UNESCAPED_UNICODE it is 54 bytes of UTF-8. Multiplied across 12 cards and a 90-second stream, this roughly halved our outbound bytes on JP pages.
  • Cloudflare Auto Minify (when accidentally left on for the API path) treated the data: line as JavaScript and stripped a backslash inside a Korean string. Disable Auto Minify on the SSE path.

For SQLite FTS5 with the CJK tokenizer (which we use for search elsewhere in the app), do not include FTS columns in the row you stream. The tokenized text duplicates payload size for no benefit on a numeric counter.

Common Pitfalls

  • session_start() inside the SSE endpoint serializes all of a user's requests on the session file lock. Do not.
  • ignore_user_abort(true) plus a loop with no connection_aborted() check will keep a worker pinned forever after the client leaves. Use false.
  • PDO connections do not automatically die when the client disconnects; the worker must break out of the loop.
  • Do not put any access token in the URL. Cloudflare logs URL paths. Use cookies, which the EventSource API sends automatically for same-origin requests.
  • Test with the network throttled to Slow 3G in DevTools. If your first event waits for a buffer to fill, you will see the symptom there before users complain.

Conclusion

SSE for live view counts gives you most of what WebSockets offer for the actual use case — push, reconnect, headers, auth — without leaving the request/response model your stack is already built around. On a PHP 8.4, LiteSpeed and Cloudflare setup the only real work is defeating output buffering, picking a sensible tick interval, and capping connection lifetime so workers cycle. The aggregator writes, the SSE endpoint reads, the browser displays. After two months in production the noticeable wins were less code than the original poll loop, half the bandwidth on CJK pages, and a watch page where the view count actually feels alive.

Top comments (0)