Tuesday, December 5, 2023

Streaming HTTP response in PHP - turn long-running process into realtime UI

Streaming is not a new concept, it is a data transfer technique which allows a web server to continuously send data to a client over a single HTTP connection that remains open indefinitely. In streaming response comes in chunk rather than sending them at once. In the traditional HTTP request / response cycle, a response is not transferred to the browser until it is fully prepared which makes users wait.
Output buffering allows to have output of PHP stored into an memory (i.e. buffer) instead of immediately transmitted, it is a mechanism in which instead of sending a response immediately we buffer it in memory so that we can send it at once when whole content is ready.
Each time using echo we are basically telling PHP to send a response to the browser, but since PHP has output buffering enabled by default that content gets buffered and not sent to the client. But we will tell PHP to send output immediately then appear rather keep them wait until execution completed.
Php script will be like below which will usually send content chunk by chunk:
<?php
// Making maximum execution time unlimited
set_time_limit(0);              

// Send content immediately to the browser on every statement that produces output
ob_implicit_flush(1);           

// Deletes the topmost output buffer and outputs all of its contents
ob_end_flush();                 

sleep(1);
echo sprintf("data: %s%s", json_encode(["content" => "Stream 1"]), "\n\n");

sleep(2);
echo sprintf("data: %s%s", json_encode(["content" => "Stream 2"]), "\n\n");

sleep(3);
echo sprintf("data: %s%s", json_encode(["content" => "Stream 3"]), "\n\n");

exit;
Output buffers catch output given by the program. Each new output buffer is placed on the top of a stack of output buffers, and any output it provides will be caught by the buffer below it. The output control functions handle only the topmost buffer, so the topmost buffer must be removed in order to control the buffers below it.

✔ The ob_implicit_flush(1) enables implicit flushing which sends output directly to the browser as soon as it is produced.

✔ If you need more fine grained control then use flush() function. To send data even when buffers are not full and PHP code execution is not finished we can use ob_flush and flush. The flush() function requests the server to send it's currently buffered output to the browser

How to get and process the response in javascript

There is a simple example how we can do it with traditional xhr ( XMLHTTPRequest ) request
function doCallXHR() {
    let lastResponseLength = 0;
    let xhr = new XMLHttpRequest();
    xhr.open("POST", "/do", true);
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.setRequestHeader("Accept", "application/json");
    xhr.onprogress = (e:any) => {
        let response = e.currentTarget.response;
        let progressResponse = lastResponseLength > 0 ? response.substring(lastResponseLength) : response;
        lastResponseLength = response.length;
        console.log(new Date().toUTCString());
        progressResponse.split(/\n\n/g).filter((t) => t.trim().length > 0).forEach((line) => {
            console.log(JSON.parse(line.substring(6)));
        });
    };
    xhr.onreadystatechange = () => {
        if (xhr.readyState == 4) {
            console.log("Status = " + xhr.status);
            console.log("Complete = " + xhr.responseText);
        }
    };
    xhr.send();
}
doCallXHR();
Output is as below from browser console:

Wed, 10 Jul 2024 13:33:36 GMT
{content: 'Stream 1'}
Wed, 10 Jul 2024 13:33:38 GMT
{content: 'Stream 2'}
Wed, 10 Jul 2024 13:33:41 GMT
{content: 'Stream 3'}

Status=200
Complete=data: {"content":"Stream 1"}

data: {"content":"Stream 2"}

data: {"content":"Stream 3"}
xhr.onprogress: is the function called periodically with information until the XMLHttpRequest completely finishes

Few points to note​

✅ we are sending chunk response from server and in xhr onprogress getting every new response part merged with the previously received part.

✅ it is possible to load the response one at a time as server response is multiple parts & in a format one after another. We can do it by substracting previoud response string length and parsing with JSON.parse

What if any error / exception occur! how to react to that?​

That's easy.. catch the error & respond with a status that the front-end js script can react to
<?php
try {
    $response = $this->expensiveProcessing();
} catch(\Exception $e) {
    // Handle the exception
    echo json_encode([
        'success' => false, 
        'message' => $e->getCode() . ' - '. $e->getMessage(), 
        'progress' => 100
    ]);

    ob_end_flush();
    exit;
}
Configuration for Nginx​

You need to do few tweaking with nginx server before working with output buffering.

fastcgi_buffering off;
proxy_buffering off;
gzip off;

For whichever reason if you don't have access to nginx server configuration then from PHP code you can also achieve the same result via HTTP header

header('X-Accel-Buffering: no');

No comments:

Post a Comment