Why SSE Beats WebSockets for Token Streaming
If you're building an AI interface, waiting 8 seconds for a complete response from GPT-4o destroys user experience. Streaming tokens as they arrive drops your time-to-first-token (TTFT) from 8,000ms down to under 250ms. While WebSockets work, Server-Sent Events (SSE) are simpler, run over HTTP/2 without connection upgrades, and reconnect automatically when network drops happen.
I'll show you how to build a streaming endpoint in Laravel 12 on PHP 8.3 and consume it in a Next.js 16 frontend running React 19. We'll handle the nasty edge cases: output buffer leaks, client backpressure, and broken JSON chunks from upstream APIs.
Building the SSE Controller in Laravel 12
Laravel's Symfony\Component\HttpFoundation\StreamedResponse lets us pipe chunks directly to the client. But PHP defaults to aggressive output buffering. If you don't disable fastcgi buffers and implicit flushing, PHP holds tokens until the script terminates or hits 4KB, completely defeating streaming.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use GuzzleHttp\Client;
class AIStreamController extends Controller
{
public function __invoke(Request $request): StreamedResponse
{
$prompt = $request->input('prompt');
$response = new StreamedResponse(function () use ($prompt) {
if (ob_get_level() > 0) {
ob_end_clean();
}
$client = new Client();
$upstream = $client->post('https://api.openai.com/v1/chat/completions', [
'headers' => [
'Authorization' => 'Bearer ' . config('services.openai.key'),
'Content-Type' => 'application/json',
],
'json' => [
'model' => 'gpt-4o',
'stream' => true,
'messages' => [['role' => 'user', 'content' => $prompt]],
],
'stream' => true,
]);
$body = $upstream->getBody();
while (!$body->eof()) {
$line = $this->readLine($body);
if (empty($line)) continue;
if (str_starts_with($line, 'data: ')) {
$data = trim(substr($line, 6));
if ($data === '[DONE]') {
echo "event: stop\ndata: {}\n\n";
@flush();
break;
}
echo "data: {$data}\n\n";
@flush();
}
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache, no-transform');
$response->headers->set('Connection', 'keep-alive');
$response->headers->set('X-Accel-Buffering', 'no');
return $response;
}
private function readLine($stream): string
{
$buffer = '';
while (!$stream->eof()) {
$byte = $stream->read(1);
if ($byte === "\n") break;
$buffer .= $byte;
}
return $buffer;
}
}
Pay close attention to X-Accel-Buffering: no. If you run Nginx in front of PHP-FPM, Nginx buffers HTTP responses by default. Without that header, Nginx waits until it has collected several kilobytes before sending anything down the wire. You'll sit there staring at a blank screen for three seconds, only to watch all tokens dump onto the screen at once.
Handling Frontend Backpressure and Token State in React 19
On the Next.js 16 side, avoid the native EventSource API if you need custom headers like Bearer tokens or POST request bodies. EventSource only supports GET requests and cannot attach custom authentication headers. Instead, use fetch with ReadableStream.
When high-frequency tokens arrive every 15ms, triggering a React state re-render on every token causes browser main-thread jank. The DOM updates faster than the display refresh rate (60Hz or 120Hz), consuming 100% CPU on mobile devices.
Here is a complete Next.js 16 client component using React 19's state updater and requestAnimationFrame batching to smooth out the stream.
'use client';
import { useState, useRef } from 'react';
export default function StreamViewer() {
const [prompt, setPrompt] = useState('');
const [text, setText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const pendingBufferRef = useRef('');
const animFrameRef = useRef<number | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!prompt.trim() || isStreaming) return;
setText('');
setIsStreaming(true);
pendingBufferRef.current = '';
try {
const response = await fetch('/api/proxy-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (!response.body) throw new Error('No readable stream available');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
const flushToState = () => {
setText((prev) => prev + pendingBufferRef.current);
pendingBufferRef.current = '';
animFrameRef.current = null;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
const rawData = trimmed.replace(/^data:\s*/, '');
if (rawData === '{}') continue;
try {
const parsed = JSON.parse(rawData);
const token = parsed.choices?.[0]?.delta?.content || '';
if (token) {
pendingBufferRef.current += token;
if (!animFrameRef.current) {
animFrameRef.current = requestAnimationFrame(flushToState);
}
}
} catch (err) {
console.error('Failed to parse SSE JSON chunk:', err);
}
}
}
}
} catch (err) {
console.error('Streaming error:', err);
} finally {
setIsStreaming(false);
if (pendingBufferRef.current) {
setText((prev) => prev + pendingBufferRef.current);
}
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<form onSubmit={handleSubmit} className="mb-4">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Ask something..."
className="w-full p-3 border rounded-lg text-black"
rows={3}
/>
<button
type="submit"
disabled={isStreaming}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isStreaming ? 'Streaming...' : 'Send Prompt'}
</button>
</form>
<div className="p-4 bg-gray-900 text-green-400 font-mono rounded-lg whitespace-pre-wrap">
{text || 'Response will appear here...'}
</div>
</div>
);
}
The Network Gotchas That Break Production
Streaming worked fine on localhost, right? Then you deployed to staging behind Cloudflare or AWS ALB and tokens arrived in massive 4KB bursts. Here are three exact reasons why.
1. FastCGI and Output Buffering in PHP 8.3
Calling ob_end_clean() in PHP removes active user buffers, but PHP's output_buffering setting in php.ini can still bite you. Make sure zlib.output_compression is set to Off. Compression forces PHP to hold chunks until it has enough data to compress effectively, completely killing live token output.
2. Reverse Proxy Buffering
Nginx overrides header flags if its configuration explicitly forces proxy buffering on. Check your nginx.conf location block for your API routes:
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
}
Without proxy_http_version 1.1 and clearing the Connection header, Nginx defaults to HTTP 1.0 backends, which don't support chunked transfer encoding.
3. Split JSON Chunks Across Packet Boundaries
TCP packets do not care about your line breaks. A single SSE packet can terminate in the middle of a UTF-8 character or halfway through a JSON payload. Notice how our frontend decoder handles this: buffer = lines.pop() || '';. This retains incomplete trailing data in local memory until the remaining bytes arrive in the next chunk.
Rendering Markdown and Code Blocks Without Layout Shift
Raw text output is easy, but real apps format Markdown tokens using libraries like react-markdown. Parsing incomplete Markdown syntax (like an unclosed backtick block) throws parser errors or causes wild layout shifts as elements render, collapse, and re-render every frame.
The fix is two-fold:
- Sanitize partial Markdown before sending it to the parser, appending temporary closing tokens in memory for the preview renderer.
- Use
requestAnimationFramebatching like shown above so React isn't re-parsing the entire DOM node structure 60 times per second.
By bypassing WebSocket overhead and setting headers correctly across Nginx, PHP 8.3, and Next.js 16, your users get responsive, sub-300ms time-to-first-token streaming that feels instant.













