DEV Community

ahmet gedik
ahmet gedik

Posted on

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

Every video page on DailyWatch shows a view count, and for a long time that number was a lie. It was accurate the moment the page rendered from cache and then drifted for the next six hours until the cache expired. A video that picked up 40,000 views during a traffic spike still showed the number it had when LiteSpeed first stored the HTML. People noticed. They would refresh the page to "update" the count, which did nothing useful except cost me origin requests. I run a free video discovery platform, DailyWatch, and the whole point of the front page is that it feels alive. A frozen counter undercuts that.

The naive fix is polling: have the browser hit /api/views?id=123 every few seconds and repaint the number. That works until it doesn't. This article is about the fix I actually shipped — a Server-Sent Events (SSE) stream that pushes view-count deltas to open pages over a single long-lived HTTP connection, built on PHP 8.4, SQLite, LiteSpeed, and Cloudflare, with no message broker and no WebSocket server to babysit.

Why polling falls apart at scale

Let's put numbers on it. Say 3,000 people have a watch page open and you poll every 5 seconds. That's 600 requests per second, every second, forever, whether or not the count changed. Most of those responses are identical to the previous one. You are paying full request cost — PHP worker, SQLite open, JSON encode, Cloudflare cache miss for a dynamic endpoint — to tell the browser "nothing happened."

Polling has three specific problems that get worse with traffic:

  • Wasted work. The overwhelming majority of polls return an unchanged value. You burn CPU proving nothing moved.
  • Latency floor. With a 5-second interval, your count is on average 2.5 seconds stale and up to 5 seconds stale. Shorten the interval to fix that and you multiply the wasted work.
  • Thundering herd. If many clients loaded the page at the same second (common when a video gets shared), their poll timers align and you get periodic spikes instead of smooth load.

WebSockets solve the freshness problem but bring their own tax: you need a persistent server process, a separate upgrade path, connection state, ping/pong keepalives, and a story for scaling that process horizontally. For a number that only ever flows server → client, that's a lot of machinery. SSE is the right size for this job.

SSE in one minute

Server-Sent Events is a plain HTTP response with Content-Type: text/event-stream that never ends. The server writes small text frames and flushes; the browser's built-in EventSource parses them and fires events. The wire format is deliberately boring:

id: 42
event: viewcount
data: {"videoId":123,"views":40217}

Enter fullscreen mode Exit fullscreen mode

Each frame is a set of field: value lines terminated by a blank line. The fields you actually use are data (the payload, repeatable for multiline), event (a name your client can listen for), id (which the browser echoes back as Last-Event-ID on reconnect), and retry (reconnect delay in ms). That's the entire protocol. No framing library, no handshake, no subprotocol negotiation. It rides on ordinary HTTP/1.1 or HTTP/2, so Cloudflare, LiteSpeed, and every proxy in between already understand it.

The one thing SSE does not give you is a client → server channel. That's fine here. The browser has nothing to tell me; it just wants the current number.

A minimal SSE endpoint in PHP 8.4

Here's the core endpoint. It streams the current view count for one video and then pushes updates as they land.

<?php
declare(strict_types=1);

// /public/stream_views.php
$videoId = (int) ($_GET['id'] ?? 0);
if ($videoId <= 0) {
    http_response_code(400);
    exit('bad id');
}

// SSE headers. X-Accel-Buffering disables proxy response buffering.
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');

// Kill PHP/LiteSpeed output buffering so each frame leaves immediately.
while (ob_get_level() > 0) {
    ob_end_flush();
}

$db = new PDO('sqlite:' . __DIR__ . '/../data/app.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 = -1;
$id = (int) ($_SERVER['HTTP_LAST_EVENT_ID'] ?? 0);
$deadline = time() + 55; // cap the connection; let the client reconnect

send_frame($id, 'retry', '3000'); // browser reconnects after 3s

while (time() < $deadline) {
    if (connection_aborted()) {
        break;
    }

    $stmt->execute([$videoId]);
    $views = (int) $stmt->fetchColumn();

    if ($views !== $lastSent) {
        $lastSent = $views;
        $payload = json_encode(['videoId' => $videoId, 'views' => $views]);
        send_frame(++$id, 'viewcount', $payload);
    } else {
        // Heartbeat comment keeps the connection and idle proxies alive.
        echo ": ping\n\n";
        flush();
    }

    usleep(1_000_000); // 1s poll of the DB, not the client
}

function send_frame(int $id, string $event, string $data): void
{
    echo "id: {$id}\n";
    echo "event: {$event}\n";
    echo "data: {$data}\n\n";
    flush();
}
Enter fullscreen mode Exit fullscreen mode

A few decisions worth calling out. The connection is capped at 55 seconds and then closed on purpose. Long-lived PHP requests tie up a LiteSpeed worker for their entire lifetime, so you do not want them open for an hour. When the loop ends, PHP closes the response, EventSource notices, waits the retry interval, and reconnects with a Last-Event-ID header so you never miss a frame. You get the illusion of a permanent stream out of a series of roughly one-minute requests.

Also note the loop polls SQLite once per second, not the client. This looks like it reintroduces polling, and locally it does — but it's one cheap indexed read per connection per second inside a single process, not a full HTTP round trip through Cloudflare. We'll cut even that in the fan-out section.

Keeping SQLite reads cheap

The read in that loop hits video_counts, a table that exists only to make this fast. Do not compute a view count with SELECT COUNT(*) over a raw events table on every tick — that's a scan you'll pay for thousands of times a second. Keep a denormalized counter and bump it on write.

<?php
declare(strict_types=1);

// Called by the view-registration endpoint, not the stream.
function register_view(PDO $db, int $videoId): void
{
    // One row per video, incremented atomically. WAL mode lets the
    // SSE readers keep reading while this writer commits.
    $db->prepare(
        'INSERT INTO video_counts (video_id, views)
         VALUES (:id, 1)
         ON CONFLICT(video_id)
         DO UPDATE SET views = views + 1'
    )->execute(['id' => $videoId]);
}

// One-time schema. Run at deploy/migrate time.
function migrate(PDO $db): void
{
    $db->exec('PRAGMA journal_mode = WAL');
    $db->exec(
        'CREATE TABLE IF NOT EXISTS video_counts (
            video_id INTEGER PRIMARY KEY,
            views    INTEGER NOT NULL DEFAULT 0
        )'
    );
}
Enter fullscreen mode Exit fullscreen mode

WAL (write-ahead logging) mode is doing heavy lifting here. In the default rollback-journal mode, a writer blocks readers and vice versa; with hundreds of SSE readers looping every second, a single register_view write could stall all of them. WAL lets readers and one writer proceed concurrently, which is exactly the read-heavy, single-writer shape this workload has. The busy_timeout pragma in the stream endpoint covers the brief moments when a checkpoint does need exclusive access.

If your view count lives alongside a search index — DailyWatch uses SQLite FTS5 for discovery — keep the counter in its own tiny table rather than joining against the FTS virtual table on every tick. FTS5 is superb for matching; it's the wrong thing to hammer with a per-second point read.

Making it survive LiteSpeed and Cloudflare

This is where a "works on localhost" SSE demo dies in production, so it's worth being precise.

Buffering is the enemy. Every layer between PHP and the browser wants to buffer your output for efficiency, and buffering is death to a stream — frames pile up invisibly and arrive in a clump or never. You have to defeat it at three layers:

  • PHP: close all output buffers (the ob_end_flush loop) and call flush() after every frame. Make sure zlib.output_compression is off for this endpoint; gzip buffers by nature.
  • LiteSpeed: the X-Accel-Buffering: no header is honored by LiteSpeed the same way nginx honors it, disabling response buffering for that request. Also make sure this path is not swept into the LiteSpeed page cache.
  • Cloudflare: Cloudflare does not buffer text/event-stream responses and will hold the connection open, but only if the origin sends the right content type and no Content-Length. Never set Content-Length on a stream, and mark the path as bypass cache.

Here's the LiteSpeed/Apache config to keep the stream out of cache and compression:

# .htaccess — exclude the SSE endpoint from cache and gzip
<IfModule LiteSpeed>
    RewriteEngine On
    # Do not serve the stream from the page cache
    RewriteRule ^stream_views\.php$ - [E=Cache-Control:no-cache]
</IfModule>

<FilesMatch "stream_views\.php$">
    SetEnv no-gzip 1
    SetEnv dont-vary 1
    # Belt and suspenders: no proxy buffering
    Header set X-Accel-Buffering "no"
</FilesMatch>
Enter fullscreen mode Exit fullscreen mode

One Cloudflare-specific gotcha: on the Free plan there's an effective idle timeout of around 100 seconds — if the origin sends nothing, Cloudflare drops the connection. That's precisely why the endpoint emits a : ping comment heartbeat when the count is unchanged, and why the 55-second cap sits comfortably under the timeout. The heartbeat is a no-op for EventSource (comment lines are ignored) but keeps every proxy in the path convinced the connection is alive.

The client side

The browser half is almost anticlimactic, which is the point.

<span id="views" data-video-id="123">-</span>
<script>
  const el = document.getElementById('views');
  const videoId = el.dataset.videoId;
  let es;

  function connect() {
    es = new EventSource(`/stream_views.php?id=${videoId}`);

    es.addEventListener('viewcount', (e) => {
      const { views } = JSON.parse(e.data);
      // Only repaint if it actually changed; avoids layout thrash.
      const next = views.toLocaleString('en-US');
      if (el.textContent !== next) el.textContent = next;
    });

    // EventSource auto-reconnects on network error using the server's
    // retry value and resends Last-Event-ID. We only handle giving up.
    es.onerror = () => {
      if (es.readyState === EventSource.CLOSED) {
        setTimeout(connect, 5000);
      }
    };
  }

  // Don't stream to backgrounded tabs — reclaim the connection.
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      es?.close();
    } else if (!es || es.readyState === EventSource.CLOSED) {
      connect();
    }
  });

  connect();
</script>
Enter fullscreen mode Exit fullscreen mode

Two things earn their keep. First, closing the stream when the tab is hidden. A huge fraction of open tabs are backgrounded, and streaming to a tab nobody is looking at is pure waste — visibilitychange reclaims those connections and reopens instantly when the user returns. Second, letting EventSource do reconnection itself for network blips. You only need custom backoff for the CLOSED state, which means the server returned a non-2xx and the browser has given up permanently.

Fan-out without a message broker

The endpoint above polls SQLite once per second per connection. With a few hundred connections that's fine; SQLite point reads in WAL mode are microseconds. But you can drop the per-connection DB read almost entirely by having one process own the "what changed" question and letting the stream processes read a shared, in-memory signal.

You don't need Redis or a broker for this. A tiny sidecar that keeps the hot totals in memory and serves them over localhost is enough. Here's a compact Go broadcaster that holds the counts and serves them to the PHP streams, so PHP reads from it instead of touching SQLite every tick:

package main

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

// store holds the hot view totals in memory. A single writer (the
// view-registration path POSTs here) and many readers (the PHP SSE
// endpoints GET here), guarded by a RWMutex.
type store struct {
    mu     sync.RWMutex
    counts map[int]int64
}

func main() {
    s := &store{counts: make(map[int]int64)}

    // PHP stream endpoints poll this instead of SQLite.
    http.HandleFunc("/count", func(w http.ResponseWriter, r *http.Request) {
        id := atoi(r.URL.Query().Get("id"))
        s.mu.RLock()
        v := s.counts[id]
        s.mu.RUnlock()
        json.NewEncoder(w).Encode(map[string]int64{"views": v})
    })

    // The write path bumps the in-memory count; persist to SQLite async.
    http.HandleFunc("/bump", func(w http.ResponseWriter, r *http.Request) {
        id := atoi(r.URL.Query().Get("id"))
        s.mu.Lock()
        s.counts[id]++
        s.mu.Unlock()
        w.WriteHeader(204)
    })

    http.ListenAndServe("127.0.0.1:8790", nil)
}

func atoi(s string) int {
    n := 0
    for _, c := range s {
        if c < '0' || c > '9' {
            return 0
        }
        n = n*10 + int(c-'0')
    }
    return n
}
Enter fullscreen mode Exit fullscreen mode

Now the PHP loop replaces its SELECT with a localhost GET http://127.0.0.1:8790/count?id=... — a request that never leaves the box, never touches disk, and returns in microseconds. SQLite becomes the durable store you write through to asynchronously, not the thing you read on every tick. Whether the extra process is worth it depends entirely on your connection count: below a thousand concurrent streams I'd skip it and let WAL-mode SQLite absorb the reads; above that, the sidecar keeps disk out of the hot path.

What I watch in production

A stream you can't see is a stream that's silently broken. A few things I keep an eye on:

  • Concurrent stream count. Each open stream is one LiteSpeed worker slot for up to 55 seconds. Know your worker ceiling and cap connections per IP so one client can't open dozens.
  • Reconnect rate. A healthy client reconnects roughly once a minute (the cap). A spike in reconnects means something upstream is killing connections early — usually a proxy timeout shorter than your cap.
  • Frame lag. Timestamp writes and measure how long until the corresponding viewcount frame ships. If it climbs, your DB read or the sidecar is the bottleneck.
  • Heartbeat coverage. If you ever see Cloudflare 524s on the endpoint, your heartbeat interval is longer than the proxy's idle timeout. Shorten it.

Wrapping up

The version of this I run is boring in the best way: an ordinary PHP request that writes text and flushes, a denormalized SQLite counter in WAL mode, three headers to keep proxies from buffering, and a browser API that's been stable for a decade. No WebSocket server, no broker in the base case, no new port to firewall. The view counter on DailyWatch now updates within a second of a real view, backgrounded tabs cost nothing, and my origin request volume went down rather than up because I traded thousands of tiny polls for a handful of long-lived streams.

SSE is unfashionable precisely because it's simple, and simple is what you want for a one-directional number that has to be right. Reach for WebSockets when you genuinely need a two-way channel. For live counts, gauges, progress bars, and feeds, an event stream and a flush() will carry you further than you'd expect.

Top comments (0)