Server-Sent Events (SSE) in Laravel: A Simple Alternative to WebSockets

· 4 min read

WebSockets (Reverb/Pusher) are bi-directional. The client talks to the server, and the server talks to the client. This is great for chat.

But what if you just want to update a "Stock Price" or "Progress Bar"? You only need the server to talk to the client.

Server-Sent Events (SSE) is a standard HTTP technology that keeps a connection open and streams text data.

SSE vs WebSockets

Feature SSE WebSockets
Direction Server → Client Bi-directional
Protocol HTTP WS/WSS
Reconnection Automatic Manual
Binary data No Yes
Complexity Low Higher
Firewall issues None Possible

Why SSE?

  • Simpler: Works over standard HTTP. No special protocol (ws://).
  • Firewall friendly: It's just a normal request.
  • Auto-reconnection: The browser handles retries automatically.
  • Event IDs: Built-in support for resuming from last received event.
  • Native browser support: No JavaScript library required.

Implementation in Laravel

We use a StreamedResponse to keep the connection open.

The Controller

namespace App\Http\Controllers;

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

class StreamController extends Controller
{
    public function stream(): StreamedResponse
    {
        return response()->stream(function () {
            while (true) {
                // Fetch real data
                $data = [
                    'time' => now()->toTimeString(),
                    'stock' => $this->getStockPrice(),
                    'notifications' => $this->getUnreadCount(),
                ];

                // SSE format: "data: {json}\n\n"
                echo "data: " . json_encode($data) . "\n\n";
                
                // Flush output buffer
                if (ob_get_level() > 0) {
                    ob_flush();
                }
                flush();

                // Check if client disconnected
                if (connection_aborted()) {
                    break;
                }

                // Wait before next update
                sleep(1);
            }
        }, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'Connection' => 'keep-alive',
            'X-Accel-Buffering' => 'no', // Disable nginx buffering
        ]);
    }
}

The Route

Route::get('/stream', [StreamController::class, 'stream']);

The Frontend Client

JavaScript has a native EventSource API. No libraries needed!

const source = new EventSource('/stream');

source.onmessage = function(event) {
    const data = JSON.parse(event.data);
    console.log("Time:", data.time);
    console.log("Stock Price:", data.stock);
    
    // Update your UI
    document.getElementById('stock-price').textContent = data.stock;
};

source.onerror = function(event) {
    console.error("SSE Error:", event);
    // Browser will automatically reconnect
};

// Clean up when leaving page
window.addEventListener('beforeunload', () => {
    source.close();
});

Named Events

You can send different types of events:

// Server
echo "event: stock-update\n";
echo "data: " . json_encode(['price' => 150.25]) . "\n\n";

echo "event: notification\n";
echo "data: " . json_encode(['message' => 'New order!']) . "\n\n";
// Client - listen for specific events
source.addEventListener('stock-update', (event) => {
    const data = JSON.parse(event.data);
    updateStockPrice(data.price);
});

source.addEventListener('notification', (event) => {
    const data = JSON.parse(event.data);
    showNotification(data.message);
});

Event IDs and Reconnection

SSE supports automatic reconnection with event IDs:

$lastEventId = 0;

while (true) {
    $lastEventId++;
    
    echo "id: {$lastEventId}\n";
    echo "data: " . json_encode($data) . "\n\n";
    
    flush();
    sleep(1);
}

When the connection drops, the browser sends Last-Event-ID header on reconnect:

public function stream(Request $request): StreamedResponse
{
    $lastEventId = $request->header('Last-Event-ID', 0);
    
    // Resume from where client left off
    $events = Event::where('id', '>', $lastEventId)->get();
    // ...
}

Retry Interval

Control how quickly the client reconnects after disconnection:

// Tell client to retry after 5 seconds
echo "retry: 5000\n\n";

Real-World Example: Progress Tracking

public function importProgress(Request $request): StreamedResponse
{
    $jobId = $request->query('job_id');
    
    return response()->stream(function () use ($jobId) {
        while (true) {
            $progress = Cache::get("import_progress_{$jobId}", 0);
            $status = Cache::get("import_status_{$jobId}", 'pending');
            
            echo "data: " . json_encode([
                'progress' => $progress,
                'status' => $status,
            ]) . "\n\n";
            
            flush();
            
            if ($status === 'completed' || $status === 'failed') {
                break;
            }
            
            sleep(1);
        }
    }, 200, [
        'Content-Type' => 'text/event-stream',
        'Cache-Control' => 'no-cache',
    ]);
}

Considerations and Limitations

PHP-FPM Workers

Each SSE connection holds a PHP-FPM worker open. If you have 100 users, you need 100 workers. This architecture can be problematic.

Solutions:

  1. Laravel Octane (Swoole/RoadRunner): Handles concurrent connections asynchronously without blocking threads
  2. Limit connections: Close after a set time, let client reconnect
  3. Use WebSockets for high traffic: SSE is best for low-to-medium traffic

Nginx Configuration

Ensure Nginx doesn't buffer responses:

location /stream {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    chunked_transfer_encoding off;
}

When to Use SSE

Good for:

  • Live stock tickers
  • Progress bars for long-running tasks
  • Notification feeds
  • Server log tailing
  • Admin dashboard metrics
  • Sports score updates

Not ideal for:

  • Chat applications (need bi-directional)
  • Gaming (need low latency)
  • High-traffic applications with PHP-FPM

For low-traffic admin dashboards, standard PHP is fine. For high scale, use WebSockets (Reverb) or Laravel Octane with SSE.

Comments