Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Real-Time Updates with Plain PHP: Server-Sent Events

C
Carlo Fontanos
· 2 min read

Everyone's mental model of "real-time on the web" jumps straight to WebSockets - and then to the sobering part: a socket server, process management, proxy configuration. But if your updates flow one way (server → browser), the web has a simpler primitive that runs on the PHP you already have.

The client: three lines, reconnection included

const events = new EventSource('/events.php');

events.addEventListener('order', (e) => {
    showToast('New order: ' + JSON.parse(e.data).number);
});

EventSource opens a long-lived HTTP request and parses a trivial text protocol. The killer feature: automatic reconnection. Connection drops, browser reconnects, and sends a Last-Event-ID header telling you where it left off. That's the hard 20% of realtime, handled by the platform.

The server: a streaming PHP file

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');        // nginx: don't buffer this response

session_write_close();                   // CRITICAL - see below
set_time_limit(0);

$lastId = (int) ($_SERVER['HTTP_LAST_EVENT_ID'] ?? $_GET['lastId'] ?? 0);

while (true) {
    foreach (fetchOrdersSince($lastId) as $order) {
        $lastId = $order['id'];
        echo "id: {$order['id']}\n";
        echo "event: order\n";
        echo 'data: ' . json_encode($order) . "\n\n";
    }

    @ob_flush(); flush();

    if (connection_aborted()) exit;      // tab closed - free the worker
    sleep(2);                            // poll the DB, not the browser
}

The protocol is just those prefixed lines with a blank line after each message. The id: field is what powers resume - after a reconnect you only send what the client missed.

The three things that bite everyone

  • Buffering. Output buffering, gzip, proxies - anything that batches output turns your stream into one big delayed lump. Hence flush() after every message, X-Accel-Buffering for nginx, and zlib.output_compression = Off for this endpoint.
  • The session lock. An SSE endpoint that holds the session open blocks every other request from that user - the whole site appears frozen. session_write_close() before the loop is non-negotiable. (Full story here.)
  • Worker budget. Every open stream occupies a PHP worker. For a five-person admin dashboard: totally fine. For thousands of public visitors: you'll exhaust the pool - that's when you graduate to a dedicated event server. Setting retry: 15000 and closing the connection after each batch (letting clients reconnect) is a middle path that scales surprisingly far.

Where I actually use it

Admin dashboards ("new order" toasts), long-task progress (import scripts echoing percentage events), build/deploy log streaming. One PHP file each, no infrastructure, and the browser handles the fragile parts. For upload progress specifically there's an even simpler tool - XMLHttpRequest's progress events.

C
Written by Carlo Fontanos

Full-stack web developer sharing practical tutorials and building tools that ship.

Got something on your mind?

My inbox is open - no forms disappearing into the void here.

  • Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
  • Hire me for a project Have something custom in mind? Let's talk scope and timelines.
  • Product support Bought something here? I'll help you get it running.

I usually reply within 1-2 business days.

Message sent!

Your details are only used to reply to you.

Keep reading