log in
consulting hosting industries the daily tools about contact

SSE over nginx + PHP-FPM: the buffering problem nobody warns you about

Server-Sent Events look dead simple until nginx and PHP-FPM start swallowing your stream. Here's what actually happens and how to fix it.

Every SSE tutorial on the internet shows you the same 15 lines of PHP and calls it done. What they don't show you is the part where you deploy to a real nginx + PHP-FPM stack and your beautifully streaming events arrive in one giant blob — or not at all — because three different layers are buffering your output without telling you.

I've hit this on multiple projects. Most recently on a job-processing dashboard for a print management client where operators needed live progress on large batch jobs. Spent an afternoon thinking my event loop was broken. It wasn't. It was nginx.

What SSE actually is (and why it's underused)

SSE is an HTTP/1.1 long-lived response where the server keeps the connection open and pushes text/event-stream formatted chunks down to the client. The browser's EventSource API handles reconnection, event parsing, and last-event-id bookkeeping automatically. No WebSocket handshake, no socket library, no extra infrastructure.

For a surprisingly wide class of problems — progress bars, live log tailing, notification feeds, dashboard stats — SSE is the right tool. It's unidirectional (server to client), which most real-time UI updates actually are. WebSockets are overkill for that. A plain EventSource and a PHP endpoint that loops and flushes is often all you need.

The problem is that PHP, PHP-FPM, and nginx each have their own buffering layers, and they all have to get out of the way for streaming to work. Most tutorials run their demo with the built-in PHP dev server, where none of this applies.

The three buffering layers that will kill you

1. PHP output buffering. PHP has its own internal output buffer. If it's on (and in many shared or managed setups it is), your echo calls don't go anywhere until the buffer fills or the script ends.

2. PHP-FPM's FastCGI buffer. FPM accumulates the FastCGI response before handing it to nginx. This is the one that bites hardest and is least documented.

3. nginx's proxy buffer. nginx by default buffers the proxied response from FPM. It waits for the full response before forwarding to the client. That's the opposite of what you want.

You have to address all three. Miss one and you're debugging in circles.

A working Laravel SSE endpoint

Here's the controller I actually ship. It's a StreamedResponse so Laravel doesn't try to buffer the whole thing itself.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

class JobProgressController extends Controller
{
    public function stream(Request $request, string $jobId): StreamedResponse
    {
        // Validate the job belongs to this user before we open the stream
        $job = BatchJob::where('id', $jobId)
            ->where('user_id', $request->user()->id)
            ->firstOrFail();

        return new StreamedResponse(function () use ($jobId) {
            // Layer 1: kill PHP's output buffer
            if (ob_get_level() > 0) {
                ob_end_clean();
            }

            // Required headers are set via StreamedResponse, but
            // we can also disable implicit_flush paranoia here
            @ini_set('implicit_flush', 1);

            $lastStatus = null;
            $maxRuntime = 300; // 5 minutes hard limit
            $start = time();

            while (true) {
                if (connection_aborted()) {
                    break;
                }

                if ((time() - $start) > $maxRuntime) {
                    echo "event: timeout\n";
                    echo "data: {\"message\": \"Stream closed after 5 minutes\"}\n\n";
                    flush();
                    break;
                }

                $job->refresh();

                if ($job->status !== $lastStatus) {
                    $lastStatus = $job->status;

                    $payload = json_encode([
                        'status'   => $job->status,
                        'progress' => $job->progress_percent,
                        'message'  => $job->status_message,
                    ]);

                    echo "id: {$job->id}-{$job->progress_percent}\n";
                    echo "event: progress\n";
                    echo "data: {$payload}\n\n";
                    flush();
                }

                if (in_array($job->status, ['completed', 'failed'])) {
                    echo "event: done\n";
                    echo "data: {$payload}\n\n";
                    flush();
                    break;
                }

                sleep(2);
            }
        }, 200, [
            'Content-Type'      => 'text/event-stream',
            'Cache-Control'     => 'no-cache',
            'X-Accel-Buffering' => 'no',   // <-- this is the nginx kill switch
            'Connection'        => 'keep-alive',
        ]);
    }
}

The X-Accel-Buffering: no header is the one almost nobody mentions. nginx reads that header and disables proxy buffering for that specific response. No nginx config change required — the app controls it per-response. I'd rather own that in code than rely on ops getting the nginx config right.

The nginx config you still need

Even with X-Accel-Buffering: no, there's one nginx setting that can still get you: fastcgi_buffering. If it's set to on at the server or location level and you haven't set X-Accel-Buffering, you're buffered. I set both the header and the config so there's no ambiguity.

location ~ \.php$ {
    fastcgi_pass   unix:/run/php/php8.2-fpm.sock;
    fastcgi_index  index.php;
    include        fastcgi_params;
    fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;

    # For SSE routes specifically, you can turn this off globally
    # or let the X-Accel-Buffering header handle it per-response
    fastcgi_buffering off;

    # These prevent nginx from closing a slow/idle stream too early
    fastcgi_read_timeout 310s;   # slightly longer than your PHP max_runtime
    keepalive_timeout    310s;
}

If you don't control nginx config (managed hosting, some PaaS setups), the X-Accel-Buffering: no header alone is usually enough — it was designed exactly for that situation.

The gotchas that bit me

PHP-FPM's output_buffering in php.ini. If your php.ini has output_buffering = 4096 (common), calls to flush() won't do anything until the buffer fills. You need output_buffering = Off — either in php.ini, a per-directory .user.ini, or via ini_set('output_buffering', 'Off') at the top of your script before any output. The ob_end_clean() call in my controller handles the runtime buffer, but output_buffering in php.ini is a different animal — it kicks in before ob_start() is ever called explicitly.

Nginx gzip compression. If nginx is gzip-compressing responses and your SSE response matches the gzip_types, nginx will buffer the whole thing to compress it. Add text/event-stream to your gzip_disable rules or just exclude the SSE route:

location /api/jobs/stream {
    gzip off;
    # ... rest of config
}

PHP-FPM request timeouts. FPM's request_terminate_timeout will kill long-running scripts. For a 5-minute stream you need that set to at least 310 seconds, or use a pool config override for the SSE route. I usually run SSE endpoints under a separate FPM pool with a longer timeout so I'm not loosening timeouts globally.

connection_aborted() only works if ignore_user_abort is false. PHP won't actually check the connection status unless it tries to write to the socket. Make sure you're calling flush() each loop iteration — that's what triggers the check. If you're in a tight loop doing expensive DB work between flushes, add a manual if (connection_aborted()) break; after each flush.

The browser's EventSource reconnects automatically. That's usually great, but if your stream endpoint is stateful (pulling from a queue, holding a cursor), you need to handle the Last-Event-ID header that the browser sends on reconnect. I set the id: field in every event for exactly this reason.

When I'd reach for this

SSE is my first choice when:

  • The data flow is one-way (server pushes, client just renders)
  • I'm already on an HTTP/1.1 stack with no WebSocket infrastructure
  • The update frequency is measured in seconds, not milliseconds
  • I want to avoid standing up Redis pub/sub or a message broker for something simple

I'd skip SSE when:

  • I need bidirectional communication (chat, collaborative editing) — that's WebSocket territory
  • I'm on HTTP/2 behind a load balancer that terminates connections aggressively (it can work but the multiplexing behavior changes things)
  • I'm dealing with very high frequency updates (sub-second) or large payloads — long polling or a proper message queue is more appropriate
  • I need to broadcast the same event to thousands of clients simultaneously — PHP holding open thousands of FPM workers for that is a bad time

For the print management client I mentioned, SSE was exactly right. A few dozen operators, batch jobs that update every few seconds, no bidirectional need. I was done in a day including the frontend EventSource wiring. The only lost afternoon was the buffering debug session — hence this post.

One more thing about PHP worker consumption

Each open SSE connection holds a PHP-FPM worker for its entire duration. If you have pm.max_children = 20 and 20 people open a dashboard simultaneously, you've saturated your FPM pool. New requests — including normal page loads — queue or fail. Plan for this. Either size your pool appropriately, set a short max-runtime and rely on EventSource reconnect, or put SSE endpoints on a dedicated FPM pool with a separate socket. I do the latter for anything customer-facing.


SSE is genuinely useful and genuinely underused in PHP land, probably because the buffering gotchas make the first real deployment feel broken. Once you understand the three layers — PHP output buffering, FPM's FastCGI buffer, nginx proxy buffering — and you know X-Accel-Buffering: no is the escape hatch, it's actually a clean solution. The tutorials aren't wrong. They're just incomplete.

Need help shipping something like this? Get in touch.