Server-Sent Events (SSE) — One-Way Streaming with text/event-stream
Build a real PHP SSE endpoint that streams updates over a long-lived text/event-stream response using proper headers and flush(); assign event IDs so a reconnecting client can resume via Last-Event-ID; and understand precisely why SSE is one-way (server-to-client only) and how that shapes when to choose it over WebSockets.
Some features only need the server to keep pushing updates to an already-open page -- a live notification count, a progress bar for a long-running job, a stock ticker -- without the client ever needing to send anything back over that same connection. Server-Sent Events give you exactly that: a single long-lived HTTP response that stays open and keeps sending small text-formatted messages, using nothing more exotic than a regular HTTP GET request and the browser's built-in EventSource API, with automatic reconnection handled for you. It is a much simpler tool than WebSockets for the one-way case, and understanding why it's one-way -- and what that rules in and out -- is the core of this lesson.
The text/event-stream Wire Format
An SSE response is just an HTTP response with Content-Type: text/event-stream that never closes (or closes and the client auto-reconnects). Its body is a sequence of small text blocks, each ending in a blank line, made of simple field: value lines: data: carries the payload (possibly on multiple lines for a multi-line message), event: names a custom event type the client can listen for specifically, id: assigns this message an identifier for reconnection purposes, and retry: suggests how long the browser should wait before reconnecting after a drop.
Because PHP normally buffers output, a working SSE endpoint has to explicitly disable buffering (ob_implicit_flush(true), turning off output_buffering) and call flush() after every message so it actually reaches the client immediately rather than sitting in a buffer until the script ends.
• Content-Type: text/event-stream, and the connection stays open rather than closing after one response. • data: <payload> -- one or more lines, blank line terminates the message. • event: <name> -- lets the client listen for specific named events via addEventListener. • id: <value> -- lets a reconnecting client resume via the Last-Event-ID header (below). • Must call flush() (with output buffering disabled) after every message or PHP will hold it in a buffer.
Reconnection via id: and Last-Event-ID
The browser's EventSource automatically reconnects if the connection drops (network blip, server restart) -- that resilience is built into the spec, not something you write yourself. What YOU are responsible for is making reconnection resume correctly rather than replaying from the start or silently losing messages sent during the gap: every message the server sends should carry an id: line, and the browser remembers the last one it saw and automatically sends it back on reconnection as a Last-Event-ID request header.
The server reads that header on a fresh connection and, if present, replays only the events that happened after that ID (typically by querying a log/table of past events keyed by ID) before resuming the live stream -- turning a network blip from 'client silently missed some updates' into 'client transparently catches back up'.
• EventSource reconnects automatically on drop -- the browser's job, not yours. • Every message should carry id: so the browser can track 'the last one I saw'. • On reconnect, the browser sends Last-Event-ID automatically; the server reads it and replays anything missed. • Without id:/Last-Event-ID handling, a reconnect silently resumes from 'now', dropping whatever happened during the gap.
Why SSE Is Strictly One-Way
SSE is built entirely on a regular HTTP response -- there is no separate channel or protocol upgrade for the client to send data back over; the only way a client following an SSE stream can send anything to the server is a completely separate, ordinary HTTP request (a normal fetch/POST) outside the stream. This is a deliberate simplicity trade, not a missing feature: for a large class of real use cases (notifications, live counters, progress updates, log tailing) the client genuinely never needs to send anything over that channel, and SSE's simplicity -- plain HTTP, works through any proxy/load balancer that understands HTTP, automatic reconnection built into the browser -- is a real win over the operational complexity of a bidirectional protocol.
WebSockets, covered next, upgrade the connection to a genuinely bidirectional protocol where either side can send a message at any time over the same open connection. Choose SSE when data flows server-to-client only; choose WebSockets when the client also needs to push data over the same live connection (chat, multiplayer state, collaborative editing) rather than via separate request/response calls alongside the stream.
• SSE = ordinary HTTP response kept open; no protocol upgrade, no channel for the client to send data back over it. • Client-to-server communication alongside an SSE stream requires a separate, normal HTTP request outside the stream. • SSE's constraint is a deliberate simplicity trade: works through any HTTP-aware proxy, reconnects automatically, trivial to implement server-side. • Choose WebSockets instead when the client must also push data over the SAME live connection.
Minimal SSE Endpoint Setup
X-Accel-Buffering: no is needed specifically when nginx sits in front of PHP-FPM, which otherwise buffers the whole response before forwarding it. ob_implicit_flush(true) plus draining any existing output buffers is what makes flush() actually push bytes to the client immediately. The blank line (\n\n) after data: is not optional -- it's how the client's parser knows one message ended.
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // disable nginx's own response buffering
// Disable PHP's own output buffering so flush() actually sends data now
while (ob_get_level() > 0) {
ob_end_flush();
}
ob_implicit_flush(true);
function sendEvent(string $data, ?string $id = null, ?string $event = null): void
{
if ($id !== null) {
echo "id: {$id}\n";
}
if ($event !== null) {
echo "event: {$event}\n";
}
echo "data: {$data}\n\n"; // blank line terminates the message
flush();
}Code Examples
The endpoint reads Last-Event-ID to resume from wherever a reconnecting client left off, checks connection_aborted() each loop so it stops doing work once the client is gone, and sends a named 'done' event the client can listen for specifically to know the stream is finished. Expected result: A continuous stream of event: progress messages followed by a final event: done message, or silent resumption from the correct point after a reconnect
$resumeFrom = $lastEventId !== null ? (int) $lastEventId : 0;
Common mistake: Forgetting connection_aborted() and letting the loop run forever even after the browser tab is closed, quietly leaking a PHP-FPM worker (and its database connections) for every abandoned client.
<?php
declare(strict_types=1);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
while (ob_get_level() > 0) { ob_end_flush(); }
ob_implicit_flush(true);
$jobId = $_GET['job_id'] ?? '';
// Resume support: browser sends this automatically after a dropped connection
$lastEventId = $_SERVER['HTTP_LAST_EVENT_ID'] ?? null;
$resumeFrom = $lastEventId !== null ? (int) $lastEventId : 0;
set_time_limit(0); // this request intentionally runs long
$eventId = $resumeFrom;
while (true) {
if (connection_aborted()) {
break; // client navigated away or closed the tab
}
$progress = JobProgress::fetchSince($jobId, $eventId);
foreach ($progress as $update) {
$eventId = $update['id'];
echo "id: {$eventId}\n";
echo "event: progress\n";
echo 'data: ' . json_encode(['percent' => $update['percent']]) . "\n\n";
flush();
if ($update['percent'] >= 100) {
echo "event: done\ndata: {}\n\n";
flush();
exit;
}
}
sleep(1); // poll interval; a real system might use a queue/pubsub instead
}SSE vs WebSocket data flow
SSE deliberately gives up the return channel in exchange for being plain, reconnecting HTTP.
SSE (one-way): WebSocket (two-way):
Server ----data----> Client Server <==data==> Client
Server ----data----> Client Server <==data==> Client
(client sends nothing (either side sends
over this channel; anytime, same open
a separate normal HTTP connection)
request is used instead)Important Rules
Set Content-Type: text/event-stream and disable all output buffering (PHP's and, if present, nginx's) or messages will sit unsent in a buffer.
Terminate every message with a blank line -- the client's parser relies on it to know where one message ends.
Assign an id: to every message so a reconnecting client can resume from the right point via Last-Event-ID.
Check connection_aborted() in any long-running loop so an abandoned client doesn't leak a worker process forever.
Reach for SSE only when data flows server-to-client; if the client also needs to push over the same live connection, use WebSockets instead.
Where SSE shows up in real products
ChatGPT-style streaming text responses, GitHub's live build/CI log tailing, and many 'live notification badge' features all use SSE rather than WebSockets, precisely because the data only ever flows server-to-client: the client's own actions (sending a new chat message, cancelling a build) are ordinary separate HTTP requests, not messages sent back over the streaming connection itself.
Sending SSE messages without ever setting an id: field, so a reconnecting client silently resumes from 'now' with no idea what it missed.
Assign an id: to every message and read Last-Event-ID on reconnect to replay anything the client missed during the gap.
Without ID tracking, any network blip -- even a brief one -- becomes a silent gap in the data the client received, with no way for either side to detect or fix it after the fact.
Leaving PHP's default output buffering enabled and expecting flush() alone to push data to the client immediately.
Drain existing output buffers and call ob_implicit_flush(true) before the streaming loop starts.
PHP's default output buffering holds everything until the script ends or a buffer explicitly flushes; without disabling it first, flush() has nothing effective to flush and the client sees no data until the connection eventually times out.
Why is Server-Sent Events fundamentally limited to server-to-client communication? A. Browsers block clients from sending any data at all B. It's built on a regular HTTP response kept open, with no protocol upgrade or channel for the client to send data back over that same connection C. SSE requires a paid license for two-way communication D. The text/event-stream format cannot represent client-sent data
B. It's built on a regular HTTP response kept open, with no protocol upgrade or channel for the client to send data back over that same connection — SSE is just a long-lived ordinary HTTP response; there is no mechanism within that response for the client to send data back, so any client-to-server communication has to happen via a completely separate HTTP request.
What is the purpose of the id: field in an SSE message? A. It encrypts the message payload B. It lets the browser send it back as Last-Event-ID on reconnection, so the server can resume from the right point instead of the client silently missing data C. It sets the HTTP status code for that message D. It tells the browser how many retries to attempt
B. It lets the browser send it back as Last-Event-ID on reconnection, so the server can resume from the right point instead of the client silently missing data — The browser remembers the id: of the last message received and automatically sends it as a Last-Event-ID header when reconnecting, allowing the server to replay anything sent during the gap.
Modify the progress-streaming example so that if JobProgress::fetchSince() returns no new updates for 30 consecutive seconds, the server sends a comment line (: keep-alive\n\n — a line starting with a colon is an SSE comment, ignored by the client but keeps the connection from being treated as idle and closed by intermediate proxies) instead of doing nothing.
$emptyLoops = 0; ... inside the while(true) loop: if (empty($progress)) { $emptyLoops++; if ($emptyLoops >= 30) { echo ": keep-alive\n\n"; flush(); $emptyLoops = 0; } } else { $emptyLoops = 0; /* existing update-sending code */ }
Track how many empty polling loops have passed and send the ': keep-alive' line once you hit 30 seconds of no updates, then reset the counter.