Server-Sent Events (SSE)
OxPHP streams real-time data to clients over the Server-Sent Events protocol, with built-in backpressure. Set Content-Type: text/event-stream in your PHP script and call oxphp_stream_flush(). OxPHP handles the rest.
How it works
A streaming response moves through a predictable lifecycle:
- Set the headers. Your PHP script sets
Content-Type: text/event-streamviaheader()and writes SSE-formatted lines usingecho. - First flush opens the stream. The first call to
oxphp_stream_flush()sends the HTTP headers to the client and enters streaming mode. The client connection remains open. - Each flush sends a chunk. Each subsequent call to
oxphp_stream_flush()flushes buffered output as a new chunk, delivering it to the client immediately. - Backpressure kicks in when the buffer fills. OxPHP maintains an internal buffer of up to 64 chunks between the PHP worker and the client. When the buffer is full — because a slow client has not consumed earlier chunks —
oxphp_stream_flush()blocks until space becomes available. This prevents unbounded memory growth. - Cleanup on exit or disconnect. When the PHP script finishes, OxPHP closes the connection gracefully. If the client disconnects mid-stream, OxPHP detects the closed channel on the next flush, sets PHP's
connection_aborted()flag totrue, and arms a graceful bailout — portable loops that checkconnection_aborted()exit cleanly through their normal termination path, while loops that don't check it are still terminated by an implicit bailout on the following flush.
Keep event payloads small to maintain smooth throughput. Large payloads can fill the 64-chunk buffer quickly, causing PHP to block on each flush.
Examples
Basic SSE stream
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
for ($i = 0; $i < 100; $i++) {
$data = json_encode(['counter' => $i, 'time' => microtime(true)]);
echo "id: {$i}\n";
echo "event: tick\n";
echo "data: {$data}\n\n";
oxphp_stream_flush();
sleep(1);
// Send a comment heartbeat every 15 seconds to keep proxies from closing idle connections
if ($i % 15 === 0) {
echo ": heartbeat\n\n";
oxphp_stream_flush();
}
}Checking streaming state
Use oxphp_is_streaming() to check whether the current request is already in streaming mode. This is useful in middleware or shared request handlers:
<?php
if (!oxphp_is_streaming()) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
}
echo "data: {\"status\": \"connected\"}\n\n";
oxphp_stream_flush();Detecting client disconnects
Long-lived SSE loops should check connection_aborted() to break out cleanly when the client closes the connection. This matches the standard PHP / php-fpm idiom and lets the script run any cleanup logic (closing database handles, releasing locks, finishing finally blocks) before exiting:
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
$db = new PDO(/* ... */);
try {
while (!connection_aborted()) {
echo "data: " . json_encode(['ts' => time()]) . "\n\n";
oxphp_stream_flush();
sleep(1);
}
} finally {
$db = null; // runs on normal exit AND on connection_aborted exit
}If the script never checks connection_aborted(), OxPHP still terminates it via an implicit bailout on the next flush after the client disconnects, but finally blocks following code paths that bypass the flush call may not run. Prefer the explicit check for code that holds external resources.
Using native flush()
PHP's native flush() also works for streaming, but requires clearing all output buffer layers first. Prefer oxphp_stream_flush() — it manages output buffers automatically and integrates with OxPHP's backpressure system.
<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
while (ob_get_level()) {
ob_end_clean();
}
for ($i = 0; $i < 100; $i++) {
echo "data: " . json_encode(['counter' => $i]) . "\n\n";
flush();
sleep(1);
}SSE with worker mode
SSE works in both standard and worker mode. In worker mode, the streaming connection occupies the worker for the full duration of the stream. The worker handles the next request only after the script finishes.
<?php
require __DIR__ . '/../vendor/autoload.php';
$redis = new Redis();
$redis->pconnect('redis', 6379);
oxphp_worker(function () use ($redis) {
if (($_SERVER['HTTP_ACCEPT'] ?? '') !== 'text/event-stream') {
http_response_code(400);
echo json_encode(['error' => 'SSE only']);
return;
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
while (true) {
$message = $redis->brPop('events', 25);
if ($message) {
echo "data: {$message[1]}\n\n";
} else {
// No message within timeout — send heartbeat to keep the connection alive
echo ": heartbeat\n\n";
}
oxphp_stream_flush();
}
});Troubleshooting
The client receives no data until the script ends
The PHP output buffer is capturing output instead of streaming it. This happens when OB layers are active and oxphp_stream_flush() is not called.
Fix: Call oxphp_stream_flush() after each event. This function flushes all PHP output buffer layers and sends the accumulated output as a chunk.
SSE connections are closed after a few minutes
PHP's max_execution_time is firing and terminating the script. SSE streams must run longer than the configured limit.
Fix: Disable the per-request execution timer at the top of the streaming script:
set_time_limit(0);This is preferred over setting max_execution_time = 0 globally — it leaves the limit in place for non-SSE endpoints. Alternatively, if the entire instance is dedicated to long-lived streams:
; php.ini
max_execution_time = 0Intermediate proxies close idle SSE connections
Load balancers and proxies often close connections that carry no data for 30–60 seconds.
Fix: Send a comment heartbeat at regular intervals to keep the connection active:
echo ": heartbeat\n\n";
oxphp_stream_flush();oxphp_stream_flush() returns false
oxphp_finish_request() was called earlier in the same request. Once the response is finished, streaming is not possible. Check your code for inadvertent calls to oxphp_finish_request() before streaming begins.
Docker example
SSE endpoints require PHP's execution timer to be disabled or set high. Each active SSE connection occupies one PHP worker for the full duration of the stream, so size the worker pool to accommodate your expected concurrent stream count.
services:
app:
image: ghcr.io/oxphp/oxphp:0.10.0
ports:
- "8080:8080"
volumes:
- ./src:/var/www/html
environment:
DOCUMENT_ROOT: "/var/www/html/public"
ENTRY_FILE: "index.php"
PHP_WORKERS: "32"Each streaming script should call set_time_limit(0) at the top so the per-request timer does not fire mid-stream. This keeps the global max_execution_time in effect for non-SSE requests.
Best practices
- Use
oxphp_stream_flush()instead of nativeflush()for automatic output buffer management and backpressure integration. - Send periodic comment heartbeats (
: heartbeat\n\n) every 20–30 seconds to keep intermediate proxies from closing idle connections and to detect client disconnections early. - Keep event payloads small. Large payloads fill the 64-chunk buffer faster, causing PHP to stall on each flush. For large data, send an event ID and let the client fetch the full payload via a separate request.
- Disable PHP's execution timer per-script with
set_time_limit(0)for long-lived SSE endpoints, or setmax_execution_timehigh enough to cover your longest expected stream duration. - Size your worker pool for peak concurrent streams. Each active SSE connection holds one PHP worker for its full duration. Budget at least one worker per expected concurrent client, plus additional workers for regular non-SSE requests.
Notes
- Brotli compression is automatically skipped for streaming responses. Compression only applies to fully buffered responses.
oxphp_stream_flush()returnsfalseifoxphp_finish_request()was already called on the same request.- In worker mode, the worker remains occupied for the full duration of the stream and handles the next request only after the PHP script exits.
Behaviour on shutdown
When the server receives SIGTERM (a rolling deploy, a docker stop, a Kubernetes pod eviction), it drains gracefully:
- Each open SSE stream is ended cleanly on its next
flush— its handler bails as if the connection closed, soregister_shutdown_function()callbacks still run anderror_get_last()['message']readsRequest cancelled (shutdown). - HTTP/2 clients receive a
GOAWAYframe; HTTP/1.1 keep-alive connections are closed. The browser'sEventSourcereconnects automatically — to a healthy instance when a load balancer fronts the fleet. - The stream does not receive a 503: its
200headers were already sent, so the status cannot be rewritten. Design clients to resume from the last event id (sendid:with each event; on reconnect the browser sends it back as theLast-Event-IDrequest header, available in PHP as$_SERVER['HTTP_LAST_EVENT_ID']).
Ordinary (non-streaming) requests in flight at SIGTERM are not interrupted: they get the whole drain window to finish normally, and only requests still running when DRAIN_TIMEOUT_SECONDS (default 25) expires are cancelled, with ~2 more seconds to unwind. Set the orchestrator's termination grace period above DRAIN_TIMEOUT_SECONDS + 2.
"Streaming" here means any response that has already flushed chunked output — the server cannot tell a finite streaming download from an infinite event stream, so a large flushed download in flight at SIGTERM is ended early too, not just SSE. A request that called oxphp_finish_request() before SIGTERM counts as ordinary: its response is complete, and its remaining background work gets the drain window.
A handler blocked inside a single long-running native call (a native sleep(), a heavy preg_match, a blocking database query) cannot be interrupted until that call returns. In worker mode prefer the cooperative oxphp_sleep() in streaming loops — it yields to the fiber scheduler and is woken immediately on shutdown. Outside worker mode oxphp_sleep() falls back to a regular blocking sleep, so the stream reacts to shutdown at its next flush after the sleep returns.
See also
- Worker Mode — persistent PHP processes for reduced bootstrap overhead
- Timeouts — configuring or disabling the request timeout for long-lived connections
- PHP Functions — full reference for
oxphp_stream_flush()andoxphp_is_streaming() - Compression — Brotli compression behavior and which responses are compressed