Server-Sent Events (SSE) in Laravel: A Simple Alternative to WebSockets
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:
- Laravel Octane (Swoole/RoadRunner): Handles concurrent connections asynchronously without blocking threads
- Limit connections: Close after a set time, let client reconnect
- 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.