--- title: 'How to Stream a Live Report Without the Flicker' excerpt: "When your meeting ends, GeekBye's summary now fills in live instead of making you stare at a spinner. Getting streaming UI to feel calm instead of jittery took solving flicker twice — once for structured fields, once for markdown — and the second fix was a renderer we already owned." date: '2026-07-06' author: 'Steven' authorAvatar: '/images/blog/authors/steven.jpg' coverImage: '/images/blog/covers/how-to-stream-a-live-report-without-the-flicker.svg' tags: ['Engineering', 'Frontend', 'Streaming', 'GeekBye Releases'] keywords: - 'stream server sent events react without flicker' - 'streaming markdown renderer no flicker' - 'progressive ui accumulate state ref' - 'copy rich text with images clipboard' - 'sse streaming ui react re-render' - 'live updating report component stable' lastModified: '2026-07-06' tldr: 'GeekBye v1.6.13–v1.6.15 turned the post-meeting summary from a blocking "wait then dump" into a report that streams in field by field over server-sent events. The trick to making it calm rather than jittery was solving flicker twice: buffer streamed fields in a ref and publish once per chunk into one stable component tree, and render streaming markdown with a renderer built to tolerate half-finished tokens — the same one we already used for live chat. Plus rich copy via the clipboard''s HTML format, and chat-style Me/Them labels.' keyTakeaways: - 'The meeting summary went from one blocking call to progressive streaming over server-sent events, so results appear as they generate' - 'Structured-field flicker was fixed by accumulating fields in a ref and publishing once per chunk into a component tree that never unmounts — inline placeholders instead of empty-to-loaded remounts' - 'Markdown flicker was fixed with a streaming-aware renderer that tolerates half-finished tokens, rather than a vanilla one that re-parses and flickers lists and code fences' - 'The streaming markdown renderer was one we already owned for live chat — the fix was using it in a second place, not building a new one' - 'Rich copy is a clipboard-format problem: write both an HTML version with embedded images and a plain-text fallback, not a serialized-markdown string' --- Before this release, ending a GeekBye meeting meant watching a spinner. The app would send your whole transcript off for analysis, wait for the entire summary — score, key points, action items, the works — and then drop it on screen all at once. Functional, but it _felt_ slow, because you were staring at nothing while it worked. [GeekBye v1.6.13–v1.6.15](https://github.com/aiescu/geek-bye-releases/releases/tag/v1.6.13) turned that into a report that streams in live, field by field, as it's generated. The interesting part isn't the streaming — it's everything we had to do to keep streaming from looking _jittery_. Calm streaming and janky streaming are the same feature with very different execution. ## Flicker, part one: don't let every chunk thrash React The summary isn't one blob — it's structured fields (an overall score, key points, action items, and so on) that the backend emits one at a time over **server-sent events**. The naive way to render that is to update React state on every event as it arrives. Do that, and you get flicker. Each field arriving triggers its own re-render; components that were showing "empty" mount, then unmount, then remount as "loaded"; the layout twitches on every packet. The report _assembles itself_ in front of you in the worst possible way — visibly, nervously. The fix is a two-part discipline. First, accumulate the incoming fields in a **ref** — a mutable holder that doesn't trigger renders — and publish to React state once per chunk with a fresh copy, so the tree updates deliberately rather than on every micro-event. Second, render **one stable component tree the whole time**, whether streaming or done, with inline placeholders for fields that haven't arrived yet: ```tsx // accumulate in a ref, publish once per chunk partialRef.current = { ...partialRef.current, [field]: value } setPartial({ ...partialRef.current }) // one tree, never swapped; missing fields show a placeholder in place const report = isStreaming ? partial : saved {report.overallScore ?? '…'} ``` The component that shows the score never unmounts — it just shows `…` until the number lands, then the number. Nothing twitches. And the fully assembled object still gets cached to the local database at the end, so streaming UI and durable storage coexist without fighting. ## Flicker, part two: the renderer we already owned The second flicker is subtler and lives in the markdown itself. The report renders rich markdown — headings, bold, lists, code blocks. But markdown arriving _mid-stream_ is, at any given instant, **half-finished.** A list with one bullet so far. A code fence that's been opened but not closed. A bold marker with no partner yet. A conventional markdown renderer re-parses the entire string on every token, and that half-finished state parses to something different each time — so the list flickers, the code block flashes open and shut, the layout jumps as tokens complete. It's the same nervous assembly as before, one layer down. The fix was almost embarrassingly available: **we were already using a streaming-aware markdown renderer for the live AI chat.** A renderer built to tolerate incomplete tokens — to render half-finished markdown stably and only settle it as the tokens complete — instead of re-parsing from scratch each time. The report just needed to use the same one. We'd solved this exact problem for chat months earlier; the "fix" for the report was recognizing we already had the tool and pointing it at a second surface. Reuse beat rebuild. ## The bonus: rich copy is a clipboard-format problem Once the report looked good, people wanted to _paste_ it — into a doc, an email — and keep the formatting and the screenshots. The instinct is to serialize the report to a markdown string and hope the destination renders it. It usually doesn't. The real answer is that the clipboard holds _multiple representations at once_. So we write two: an **HTML version** with the formatting intact and the screenshots embedded as inline images, and a **plain-text fallback** for destinations that don't take HTML. Paste into a rich editor and you get the formatted report with pictures; paste into a plain text box and you get clean text. "Rich copy" was never a serialization problem — it was a _pick-the-right-clipboard-format_ problem. The same release also gave the report chat-style **Me / Them** labels with the two speakers aligned to opposite sides, so a transcript reads like the conversation it was, and moved your own screenshots to their own side to match. (One release in this window, v1.6.14, was a pure rebuild — no story there, and it's honest to say so.) ## Three things streaming UI taught us 1. **Buffer streamed fields, publish once per chunk.** Letting every server-sent event drive its own state update is the flicker. A ref accumulator plus a single publish per chunk, into a tree that never unmounts, turns nervous assembly into calm fill-in. 2. **Anything that streams needs a streaming-aware renderer.** A renderer that re-parses the whole string per token will flicker on half-finished markdown. Use one built for incomplete input — and if you already have one for another surface, reuse it before you build a second. 3. **Rich copy is about clipboard formats, not serialization.** Write `text/html` with embedded images _and_ a `text/plain` fallback. The clipboard was designed to carry both; use it. This is the fifth chapter of the reliability-and-polish story that becomes GeekBye v2. For the previous chapter, see [the earbuds bug](/blog/why-earbuds-made-the-other-speaker-disappear) (v1.6.12); for the next — the anatomy of a 127-commit release — [what a 127-commit release actually is](/blog/what-a-127-commit-release-actually-is) (v1.7.0); for where the same server-sent-events idea later became a whole fallback transport, [live transcription when the firewall blocks WebSockets](/blog/live-transcription-when-firewall-blocks-websockets) (v2.0.8); and for the whole arc, [the anatomy of shipping software to perfection](/blog/anatomy-of-perfection).