Real-Time Updates with Plain PHP: Server-Sent Events
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 = Offfor 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: 15000and 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.
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.