There’s a StackOverflow-shaped question that shows up constantly once teams get past the demo stage of an AI feature: they’ve got response()->stream() working from a controller, it looks right in Postman, and then it hits an Inertia frontend and... doesn’t stream. The whole response arrives at once, as if nothing changed. The answer, when someone finally explains it, is usually treated as a workaround. It isn’t one. It’s the entire architecture of Inertia doing exactly what it was built to do.
Inertia expects a single JSON payload per visit. That’s not a limitation somebody forgot to fix; it’s the mechanism the router is built on. Streaming tokens through a normal Inertia visit will silently buffer, and no amount of fiddling with headers changes that, because the problem isn’t the headers. So “streaming works with Livewire and Inertia” is a true sentence that’s doing a lot of quiet work, because it means two genuinely different things depending on which one you’re building.
Livewire actually streams inside its own request. Inertia can’t.
Livewire has wire:stream, and it’s not a workaround either; it’s a first-class part of the framework built for exactly this. A component property gets updated mid-request, and Livewire pushes those updates to the browser before the request even finishes. No Alpine needed, no manually opened fetch stream, no chunk-parsing loop you wrote yourself at 11 pm.
Inertia has no equivalent, because Inertia’s entire value proposition is treating server responses as page state, and page state is supposed to be resolved before the page renders it. Trying to force streaming through that model isn’t fighting a bug; it’s fighting the design. The actual answer for Inertia is a plain, boring, non-Inertia route that streams on its own, consumed by frontend code that already knows how to read a stream, specifically because the SDK speaks a protocol that off-the-shelf tooling already understands.
Livewire, built for real
<?php
namespace App\Livewire;
use App\Ai\Agents\SupportAgent;
use Livewire\Component;
class DocsChat extends Component
{
public string $message = '';
public string $reply = '';
public ?string $conversationId = null;
public function send(): void
{
$agent = new SupportAgent;
$stream = $this->conversationId
? $agent->continue($this->conversationId, as: auth()->user())->stream($this->message)
: $agent->stream($this->message);
$this->reply = '';
foreach ($stream as $event) {
if ($event->isText()) {
$this->reply .= $event->text;
$this->stream('reply');
}
}
$this->conversationId = $stream->response()->conversationId;
}
public function render()
{
return view('livewire.docs-chat');
}
}
<div>
<div wire:stream="reply">{{ $reply }}</div>
<input wire:model="message" wire:keydown.enter="send">
</div>
That’s the entire feature. $this->stream('reply') inside the loop pushes each token to the browser the moment it arrives, and wire:stream="reply" on the div appends it live. Nobody wrote SSE parsing. Nobody wrote a fetch loop. It’s a foreach loop over what the agent hands back and one Blade attribute.
The one gotcha worth knowing before it costs you an afternoon: on the very first message, $stream->response()->conversationId isn’t available until the stream actually finishes, so don’t reach for it before the loop completes; the ID genuinely isn’t there yet.
Inertia, honestly
Point a plain route at the Vercel data protocol:
Route::get('/chat/stream', function (Request $request) {
return (new SupportAgent)
->stream($request->input('message'))
->usingVercelDataProtocol();
});
That route has nothing to do with Inertia. It’s not returned from an Inertia response; it’s not part of a page visit; it’s a plain endpoint that happens to sit next to your Inertia app. The reason this still counts as “no custom JS” is that the Vercel AI SDK’s frontend package already speaks this exact protocol, useChat from ai/react (or the Vue equivalent) points at that URL and handles the token-by-token parsing, the reconnection behavior, the message state, all of it, because someone else already wrote and maintains that code.
import { useChat } from 'ai/react';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: '/chat/stream',
});
return (
<div>
{messages.map(m => (
<div key={m.id}>{m.content}</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
</form>
</div>
);
}
Your Inertia pages keep working exactly as they always have. This chat component happens to talk to the server through its own door instead of Inertia’s.
What actually breaks once real users show up
None of what follows shows up in a demo with one browser tab open, which is exactly why it’s worth writing down instead of discovering it at 2 am.
A streaming response holds a PHP-FPM worker, or an Octane worker, for the entire duration of the stream, not the usual fraction of a second a normal request takes. If your worker pool is sized the way it always has been, a handful of people leaving a chat open is enough to start queuing everyone else. Size the pool for long-held connections specifically, not for the request pattern you’re used to, and put real rate limiting in front of the streaming endpoint. One client that opens a connection and never closes it, intentionally or not, is a slow leak on your available workers, and it won’t show up in your metrics as anything obviously wrong until it already is.
Worth a quick gut check before shipping, too: streaming behavior in this SDK has had real regressions between versions before, not a knock on the package, just a young ecosystem moving fast. Pin your version and actually test a live stream after any upgrade; don’t assume it still works because the rest of your test suite is green.
What this actually was
Same agent, same stream() call, two completely different delivery mechanisms depending on which frontend framework’s rules you’re playing by. Livewire streams because Livewire owns the whole request. Inertia streams because it steps outside its own request entirely and lets a purpose-built client handle the wire format. Neither one required you to write SSE parsing by hand, and neither one is a workaround; they’re just genuinely different answers to a question that only sounds like it has one answer.



