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 for thirty seconds

The bug report was three words: "view counts wrong." It wasn't wrong, exactly. It was stale. On TrendVidStream we surface trending videos across eight regions, and the view counter on each watch page updated only when the visitor reloaded. A video could pick up forty thousand views during an evening spike in the US and GB regions while the number on screen sat frozen at whatever it was when the page rendered. People reloaded compulsively to watch it climb, which hammered our origin and our SQLite database, and still felt sluggish. I run the backend at TrendVidStream, and this is the story of replacing that reload-to-refresh loop with a one-way live stream using Server-Sent Events — on a PHP 8.4 stack, behind LiteSpeed, with a hard 180-second request timeout to work around.

The naive fix is client-side polling: setInterval, hit a JSON endpoint every few seconds, repaint the number. We ran that for a week. It works, but it is wasteful in a way that scales badly. Every open tab fires a request on its own clock, so traffic arrives as uncorrelated spikes instead of a smooth line. Each request pays the full cost of TLS resumption, PHP-FPM worker pickup, a DB read, and a JSON response — to deliver a number that, most of the time, has not changed. With a few thousand concurrent watchers across regions, polling turned into a self-inflicted load test.

Server-Sent Events fix the economics. One long-lived HTTP connection, server pushes updates only when the count actually moves, and the browser handles reconnection for free. No new protocol, no WebSocket upgrade dance, no extra infrastructure. It is the right tool when data flows one direction — server to client — which is exactly what a view counter is.

Why SSE and not WebSockets or polling

Before any code, the decision. Three options, and the trade-offs are concrete:

  • Short polling: dead simple, works everywhere, but every client adds a steady request rate regardless of whether anything changed. Latency is bounded below by your interval. Load is proportional to clients times frequency.
  • WebSockets: full duplex, lowest latency, but overkill for one-directional data. You need a WebSocket-capable server process, sticky sessions, and a heartbeat strategy. On FTP-deployed shared LiteSpeed hosting, running a persistent socket server is painful or impossible.
  • Server-Sent Events: one-directional server push over plain HTTP. Built-in auto-reconnect, built-in event IDs for resuming, works through any HTTP/1.1 or HTTP/2 proxy that doesn't buffer. The browser API is four lines of JavaScript.

For a view counter, view counts only flow outward. The client never sends anything except the initial GET. SSE is the match. The one real constraint: SSE over HTTP/1.1 consumes a connection from the browser's six-per-host limit, but over HTTP/2 (which all our regions serve via Cloudflare) connections are multiplexed and that limit evaporates.

A minimal SSE endpoint in PHP 8.4

The wire format is almost insultingly simple. You set Content-Type: text/event-stream, then write text frames separated by blank lines. Each frame is a set of field: value lines. The fields that matter are event, data, id, and retry. A line starting with : is a comment, which doubles as a keep-alive ping.

Here is the endpoint we ship. Note the bounded loop — this is the critical adaptation for LiteSpeed's request timeout. Rather than looping forever, we run for a fixed window just under the server limit and let the browser reconnect, which it does automatically.

<?php
// public/view-stream.php
declare(strict_types=1);

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
// Tell nginx/LiteSpeed proxies not to buffer this response:
header('X-Accel-Buffering: no');

// PHP and LiteSpeed both like to buffer. Tear it all down.
while (ob_get_level() > 0) {
    ob_end_flush();
}

$videoId = $_GET['v'] ?? '';
if (!preg_match('/^[A-Za-z0-9_-]{11}$/', $videoId)) {
    http_response_code(400);
    exit;
}

$db = new PDO('sqlite:/var/www/trendvidstream/data/videos.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_TIMEOUT, 5);
$db->exec('PRAGMA query_only = 1;'); // this connection only reads
$stmt = $db->prepare('SELECT views FROM video_stats WHERE video_id = :id');

// Resume support: if the browser reconnects it sends the last id it saw.
$lastSent = isset($_SERVER['HTTP_LAST_EVENT_ID'])
    ? (int) $_SERVER['HTTP_LAST_EVENT_ID']
    : null;

// Tell the client to wait 3s before reconnecting after a drop.
echo "retry: 3000\n\n";

$deadline = time() + 160; // stay under the 180s LiteSpeed ceiling

while (!connection_aborted() && time() < $deadline) {
    $stmt->execute([':id' => $videoId]);
    $views = $stmt->fetchColumn();

    if ($views !== false && (int) $views !== $lastSent) {
        $views = (int) $views;
        echo "event: views\n";
        echo 'id: ' . $views . "\n";
        echo 'data: ' . json_encode(['v' => $videoId, 'views' => $views]) . "\n\n";
        $lastSent = $views;
    } else {
        echo ": ping\n\n"; // comment frame keeps the connection warm
    }

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

A few details that took production traffic to learn. The PRAGMA query_only = 1 makes it impossible for this hot, long-running connection to accidentally take a write lock. The sleep(2) is the poll-the-DB interval — yes, the server still polls SQLite, but once per connection instead of once per client request, and it only emits bytes to the network when the number changes. The keep-alive comment frame every two seconds stops idle intermediaries from killing the connection as dead. And id: carries the view count itself, which becomes the Last-Event-ID on reconnect so a resumed stream doesn't re-emit a value the client already painted.

The browser side is almost nothing

The EventSource API does the heavy lifting: it opens the connection, parses frames, dispatches events, and reconnects on failure with the retry interval you specified. Here is the entire client.

// assets/live-views.js
function startViewStream(videoId, el) {
  const es = new EventSource(`/view-stream.php?v=${encodeURIComponent(videoId)}`);
  const fmt = new Intl.NumberFormat();

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

  es.onerror = () => {
    // EventSource auto-reconnects; we just note the gap.
    // Stop entirely once the tab is hidden for a while to save battery.
    if (document.visibilityState === 'hidden') es.close();
  };

  // Reconnect when the user returns to the tab.
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible' && es.readyState === 2) {
      startViewStream(videoId, el);
    }
  });
}

document.querySelectorAll('[data-live-views]').forEach((el) => {
  startViewStream(el.dataset.videoId, el);
});
Enter fullscreen mode Exit fullscreen mode

The visibility handling matters more than it looks. A background tab streaming forever drains mobile batteries and holds a connection for nothing. Closing on hide and reopening on show cut our idle connection count by more than half, because most watch-page tabs are not the foreground tab.

Making LiteSpeed and proxies actually stream

This is where SSE projects die quietly. Your code is correct, you flush religiously, and the browser still receives the entire response in one lump after the connection closes — because something between PHP and the browser is buffering. There are usually three culprits, and you have to disable all of them.

First, PHP's own output buffering, which the ob_end_flush loop above handles. Second, the web server. For LiteSpeed and nginx, the X-Accel-Buffering: no header disables response buffering for that one request. Third, compression — gzip buffers by definition, because it needs a window of bytes to compress. You must exclude the event-stream content type from compression.

On our LiteSpeed boxes, deployed by FTP with an .htaccess we sync alongside the app, the relevant block looks like this:

# .htaccess — disable buffering and compression for the SSE endpoint
<IfModule LiteSpeed>
    # Don't gzip event streams; gzip buffers and breaks live flushing.
    <FilesMatch "view-stream\.php$">
        SetEnv no-gzip 1
        SetEnv dont-vary 1
    </FilesMatch>
</IfModule>

<IfModule mod_headers.c>
    <FilesMatch "view-stream\.php$">
        Header set X-Accel-Buffering "no"
        Header set Cache-Control "no-cache, no-transform"
    </FilesMatch>
</IfModule>
Enter fullscreen mode Exit fullscreen mode

The no-transform in Cache-Control is the one people forget. Cloudflare and other CDNs will happily "optimize" a response by buffering and re-chunking it unless you forbid transformation. With Cloudflare in front of all eight regions, no-transform plus an uncacheable content type is what lets a byte written in PHP reach the browser within the same second.

One more LiteSpeed-specific note from painful experience: keep the per-connection lifetime comfortably under the server's request timeout. We use 160 seconds against a 180-second ceiling. If you race the timeout, LiteSpeed kills the request mid-frame, the client sees an error rather than a clean close, and although it reconnects, you get a visible stutter. Ending the loop yourself produces a graceful close and an immediate, seamless reconnect.

The write path: coalescing increments in SQLite

The read side streams fine, but where do the numbers come from? Our regional cron jobs fetch fresh view totals from upstream APIs, and live page activity also contributes local increments. The danger with SQLite is write contention: many writers fighting over a single-writer database produce SQLITE_BUSY errors and lock timeouts, which is catastrophic when you also have dozens of long-lived reader connections open.

The fix is two-fold: WAL mode so readers never block the writer, and coalescing — never increment per event, batch them. We buffer increments in a separate, tiny table and fold them into the main stats table on a short interval, so the heavily-read video_stats row is touched in one transaction rather than thousands.

-- One-time setup: WAL lets readers and the writer coexist.
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;

-- Increments land here, cheaply, append-only style.
CREATE TABLE IF NOT EXISTS view_deltas (
    video_id TEXT NOT NULL,
    delta    INTEGER NOT NULL,
    ts       INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_deltas_video ON view_deltas(video_id);
Enter fullscreen mode Exit fullscreen mode
<?php
// cron/flush_view_deltas.php — run every 5 seconds via a tight cron wrapper
declare(strict_types=1);

$db = new PDO('sqlite:/var/www/trendvidstream/data/videos.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA busy_timeout = 5000;');

$db->beginTransaction();
try {
    // Fold all pending deltas into the canonical counts in one pass.
    $db->exec('
        UPDATE video_stats
        SET views = views + COALESCE((
            SELECT SUM(delta) FROM view_deltas
            WHERE view_deltas.video_id = video_stats.video_id
        ), 0)
        WHERE video_id IN (SELECT DISTINCT video_id FROM view_deltas)
    ');
    $db->exec('DELETE FROM view_deltas');
    $db->commit();
} catch (Throwable $e) {
    $db->rollBack();
    error_log('view delta flush failed: ' . $e->getMessage());
}
Enter fullscreen mode Exit fullscreen mode

Now the architecture is clean. Writers append cheap deltas. A 5-second flush folds them into the canonical row. The SSE endpoints read that row on their own 2-second cadence and stream changes out. SQLite, which the rest of our discovery layer already leans on hard — including FTS5 for search across the catalog — handles all of it on a single file with no external dependency, because WAL keeps the readers and the single writer out of each other's way.

Fan-out without polling the database at all

The DB-polling SSE endpoint scales to thousands of connections fine, because the read is an indexed primary-key lookup and the working set lives in page cache. But each PHP connection holds a PHP-FPM worker hostage for its whole lifetime, and workers are finite. At some point you want true push: a single process that watches the data and broadcasts to all subscribers, so the per-connection cost is a cheap goroutine or coroutine instead of a full PHP worker.

For regions where we run our own box rather than shared hosting, we put a small Go service in front. It tails the delta flush, holds the fan-out, and speaks the identical SSE wire format so the browser code does not change at all.

// viewhub.go — one process, many subscribers, zero per-client DB reads
package main

import (
    "fmt"
    "net/http"
    "sync"
)

type Hub struct {
    mu   sync.RWMutex
    subs map[string]map[chan int]struct{} // videoId -> set of subscriber channels
}

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

func (h *Hub) Publish(videoID string, views int) {
    h.mu.RLock()
    defer h.mu.RUnlock()
    for ch := range h.subs[videoID] {
        select {
        case ch <- views: // non-blocking: a slow client never stalls the hub
        default:
        }
    }
}

func (h *Hub) stream(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }
    videoID := r.URL.Query().Get("v")
    ch := make(chan int, 4)

    h.mu.Lock()
    if h.subs[videoID] == nil {
        h.subs[videoID] = make(map[chan int]struct{})
    }
    h.subs[videoID][ch] = struct{}{}
    h.mu.Unlock()

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

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

    for {
        select {
        case <-r.Context().Done():
            return
        case views := <-ch:
            fmt.Fprintf(w, "event: views\nid: %d\ndata: {\"v\":\"%s\",\"views\":%d}\n\n",
                views, videoID, views)
            flusher.Flush()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A single Publish call from whatever watches the deltas now reaches every subscriber to that video with no database read on the hot path. The non-blocking select is load-bearing: one slow client must never back-pressure the broadcast. We feed Publish from the same flush step that updates SQLite, so the Go hub and the PHP endpoints stay consistent — the PHP path remains the fallback on FTP-deployed regions, and the Go path serves the high-traffic ones, both producing byte-identical SSE that the EventSource client cannot tell apart.

What it cost and what broke

The migration paid off in concrete numbers. Request volume to the watch-page JSON endpoint dropped by roughly 90 percent, because thousands of three-second polls collapsed into one connection per viewer that emits bytes only on change. Origin CPU during evening spikes fell noticeably, and the counter now ticks live without a single reload.

But a few things broke first, and they are worth warning about:

  • Buffering, always buffering. Every failure to stream traced back to a buffer somewhere — PHP, the web server, gzip, or the CDN. The no-transform and disabled-gzip rules are non-negotiable.
  • Connection accounting. On HTTP/1.1, six connections per host means six tabs can starve a user. HTTP/2 multiplexing made this a non-issue, but verify your CDN actually serves the stream over h2.
  • The PHP worker ceiling. Long-lived SSE connections each pin a PHP-FPM worker. Bound the connection lifetime, close on hidden tabs, and move your busiest regions to a fan-out process before you run out of workers.
  • Idempotent resume. Without id: and Last-Event-ID handling, every reconnect re-pushes the current value and the counter visibly flickers. Carry the last value as the event id and skip it on resume.

Conclusion

Server-Sent Events are the unglamorous, correct answer to a one-directional live-data problem. No new protocol, no socket server, a wire format you can write by hand, and a browser API that reconnects itself. For a live view counter on a PHP 8.4 and SQLite stack deployed over FTP, the whole thing is one endpoint that streams changes, an .htaccess block that gets the buffering out of the way, a coalescing write path that keeps SQLite happy under WAL, and an optional Go hub for the regions where you want true push instead of per-connection polling. Start with the PHP poll-and-stream version — it is fifty lines and ships today — and only reach for the broadcast hub when your worker count, not your users, becomes the bottleneck.

Top comments (0)