[Guide] Build a Chat App - implementation notes
The Chat guide is leaning into motion: polling and streams together, render-fetch for room reads, a global route store for room selection and mode, and a composer that keeps DOM truth and state truth in sync while still surfacing route behavior as evidence.
If you have built a chat-like feed, how do you decide between polling, streams, or both? How do you keep the route from re-reading too aggressively when render-fetch is part of the contract? What evidence do you render so you can tell whether the feed is fresh, lagging, or retrying?
Comments (10)
Back to latestPoll plus stream seems redundant until you ship it. Streams drop. Polling is the safety net.
The key to making it manageable is making the posture visible.
If you cannot tell which lane you are in (poll-only, stream-only, both), you will debug ghosts.
Related: Data Fetching and Caching Best Practices and Performance and Rendering Best Practices.
We ended up expressing the lane in route state so anyone could reproduce behavior by sharing a URL:
ts
type Lane = 'poll' | 'stream' | 'both';
const route = useGlobalRoute('chat', { lane: 'both' as Lane });
Then the shell renders data-lane and support screenshots become actionable.
Counterpoint: streams can cause a lot of UI churn if you render every message as it arrives.
We had to batch updates or the app felt jittery even though the data was fresh.
Agree. Freshness is not the only axis. We ended up batching renders and storing derived evidence like lastBatchAt.
The guide does not go deep there, but it fits the posture: keep motion, but make it legible.
Batching + evidence is a good combo. Otherwise you just get a fast app that feels chaotic.
On render-fetch aggressiveness: we had to define a cache posture for room summaries or the route would re-read too often.
Once posture was explicit, the behavior stopped being mysterious.
Composer flexibility matters. We had a composer that felt slow because every keystroke was treated as route truth.
Keeping DOM truth alive while committing state on boundaries made typing feel normal again.
A misunderstanding I see: people assume poll and stream means duplicated messages.
If you keep a stable message id contract and store derived de-dupe evidence, it stays clean.
Do you store a separate set of ids, or just derive it from the list?
We got burned by deriving it repeatedly in render on big rooms.
We stored it as derived state in the feed document.
The list stays for rendering; the id set stays for de-dupe and quick membership checks.
A tiny repro of the churn problem Keira mentioned:
tsx
stream.onMessage((m) => setMessages((prev) => [...prev, m]));
If this causes a render per message, batching becomes part of the contract. Otherwise INP goes sideways fast.
I would love a follow-up that connects this to route-level perf budgeting.
Chat is where you discover that freshness and calmness are competing constraints.
The guide is good because it keeps repeating the same principle: keep motion, but keep it legible.
If you cannot prove which lane you are in, the feed will become a pile of folklore fixes.