My app has an AI chat feature built with Livewire. When the AI streams a response, users see the text appear word by word. The problem: every bold sentence briefly flashed as **text** before turning bold. Paragraph breaks were missing until the stream finished. It looked broken.
What I saw
when I use the hypothesis builder there is a brief (sub second) flash of the markdown text before the HTML text is rendered, is there any way to prevent this from happening?
The AI API returns markdown — **bold**, line breaks, headings. That markdown needs to be converted to HTML before the user sees it. The question was where and when that conversion happened.
The architecture that caused the problem
The original setup had five steps:
- Server streams raw markdown text deltas to a hidden div via Livewire’s
wire:stream(append mode) - An Alpine.js
MutationObserverwatches the hidden div for changes - On each mutation, it reads
innerTextfrom the hidden div - It passes the raw text through
marked()— a client-side JavaScript markdown parser — to convert to HTML - It injects the resulting HTML into a separate visible output div
{{-- Hidden div collects raw streamed text --}}
<div x-ref="stream" wire:stream="response-{{ $key }}" class="hidden"></div>
{{-- Visible div shows parsed HTML --}}
<div x-ref="output" wire:ignore x-html="content ? marked(content) : ''"></div>
const observer = new MutationObserver(() => {
let txt = target.innerText;
if (txt && txt.trim() !== '') {
content = txt;
$refs.output.innerHTML = marked(txt || '');
loading = false;
}
});
The core problem: marked() was called on every partial chunk, and partial markdown tokens are unparseable. When the buffer contains **What observation (opening ** with no closing **), the parser can’t make it bold, so it renders the literal ** characters. Then when the closing ** arrives in the next chunk, it briefly renders correctly — causing the flash.
What I tried that didn’t work
I went through several client-side approaches before finding the right answer:
- Stripping all markdown syntax during streaming — lost paragraph breaks and formatting entirely
- A
renderContent()method that stripped only asterisks during streaming — caused a visible layout shift when formatting snapped in after streaming completed - CSS suppression (
:class="streaming && '[&_strong]:font-normal'") — hid the bold styling during streaming but still showed literal**characters
take a step back and think through this carefully, what are the options to properly resolve this? should I just drop bold formatting entirely? I don’t understand why we can’t stream bold text? why do we have to do the markdown after it’s rendering to the user?
That question led to the real answer. The problem wasn’t how the markdown was parsed — it was where.
The fix: move markdown conversion server-side
Instead of streaming raw markdown to the browser and parsing it with JavaScript, the server now converts markdown to HTML before streaming:
foreach ($stream as $event) {
if ($event instanceof TextDeltaEvent && ! empty($event->delta)) {
$streamBuffer .= $event->delta;
$displayBuffer = $this->stripUnclosedMarkdown($streamBuffer);
$html = str($displayBuffer)->markdown([
'html_input' => 'strip',
'allow_unsafe_links' => false,
])->toString();
$this->stream(to: 'response-'.$currentIndex, content: $html, replace: true);
}
}
Two things make this work:
replace: true — instead of appending each chunk (which would duplicate content), the entire HTML output replaces the div’s content on every update. The server re-renders the full buffer each time, so the browser always has a complete, properly formatted document.
stripUnclosedMarkdown() — even server-side, str()->markdown() can’t handle incomplete tokens. **Hello renders as literal **Hello because there’s no closing **. This helper counts ** pairs and strips any unpaired trailing one:
private function stripUnclosedMarkdown(string $text): string
{
if (preg_match_all('/\*\*/', $text) % 2 !== 0) {
$text = preg_replace('/\*\*(?!.*\*\*)/', '', $text);
}
return $text;
}
The Blade template became much simpler — no hidden div, no Alpine state management, no client-side parsing:
<div
x-ref="output"
wire:stream="response-{{ $key }}"
class="space-y-3 text-newtext border-zinc-200 [&_strong]:font-semibold"
>
@if($message['content'])
{!! str($message['content'])->markdown([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]) !!}
@endif
</div>
What changed
| Before | After | |
|---|---|---|
| Markdown parsing | Client-side (marked JS library) | Server-side (str()->markdown()) |
| Stream mode | Append raw text to hidden div | Replace visible div with complete HTML |
| Pipeline | 5 steps (stream → hidden div → observe → parse → inject) | 1 step (receive HTML, display it) |
| Alpine.js state | loading, streaming, content, renderContent() | loading only |
| Dependencies | marked npm package (~40KB gzipped) | None added |
The marked JavaScript library was removed entirely from the project. The trade-off is calling str()->markdown() on every stream chunk server-side, but that’s negligible compared to the LLM API latency that dominates the response time.
The takeaway
When you’re streaming text that needs formatting, do the conversion as close to the source as possible. Streaming raw markup to the browser and parsing it client-side means the parser has to handle incomplete tokens on every chunk — a problem that no amount of client-side workarounds can fully solve. Converting server-side with replace: true means the browser only ever sees valid HTML.