Livestream chat
Livestream chat sits next to a video stream: thousands of viewers, a fast flow of short messages, and a list that has to stay smooth for hours. This page covers which channel type to use and how to keep memory and rendering bounded. It also covers unread counts without read events, and which features to turn off.
What users get
Example
The user opens a concert stream. Next to the video, the chat shows the newest messages and follows them as they arrive, several a second. Messages from many people scroll by in a compact list.
The user scrolls up to read a question. New messages keep arriving below, and a button offers to take them back to the latest one. When they return to the bottom, the list follows the newest messages again.
An hour later, the browser still holds only the newest few hundred messages, not every message of the stream.
How it works across the layers
Server: the livestream channel type
Stream has a built-in livestream channel type. Users don't have to be members to read and write. Any user with a valid token can watch the channel and post. Its url_enrichment setting is off by default.
Check the type's Read events setting (read_events) and turn it off. Livestream best practices recommends turning off read events, typing events and connect events on the type. It also explains the server's own message throttling. Channel types lists every setting.
The channel type's settings decide what the client may do. A setting that is off on the server is off in the client too, whatever client.config says. See Configuration.
State: the message paginator
The channel's messages live in channel.messagePaginator. Two of its settings matter for a busy channel:
stateThrottleMslimits how often the paginator publishes itsitems, and with them how often the list renders. It is 500 ms by default, so a burst of messages causes about two renders a second. Messages the user sends show at once.maxLoadedItemscaps how many messages the paginator keeps. It is off by default. See Capping the messages.
Message paginators explains both.
State: read state without read events
With read_events off, the server keeps no read state for the channel, so nothing counts as unread. Turning read events off in Read state and unread counts explains what else changes. Counting unread messages locally shows how to bring back an unread count that lives only on the device.
UI: the virtualized message list
VirtualizedMessageList renders only the messages near the visible area, so the page holds a few dozen message elements however many messages are loaded. MessageList renders every loaded message. In a livestream, use VirtualizedMessageList.
Its defaults suit a livestream. Unlike <MessageList/>, it doesn't show date separators, doesn't group consecutive messages by sender, and doesn't show a typing indicator unless you register one. MessageList and VirtualizedMessageList in Message list UI compares the two lists and their defaults.
Turning it on or off
Opening the channel
Channel doesn't load the channel. Watch it first, then pass it in. This app opens one livestream channel with the virtualized list:
import { useEffect, useState } from "react";
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
Chat,
MessageComposer,
useCreateChatClient,
VirtualizedMessageList,
} from "stream-chat-react";
import "stream-chat-react/dist/css/index.css";
const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
export const App = () => {
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: { id: userId },
});
const [channel, setChannel] = useState<StreamChannel>();
useEffect(() => {
if (!client) return;
let cancelled = false;
client
.channel("livestream", "concert")
.watch({ messages: { limit: 30 } })
.then((watchedChannel) => {
if (!cancelled) setChannel(watchedChannel);
})
.catch((error) => console.error("Could not watch the channel", error));
return () => {
cancelled = true;
setChannel(undefined);
};
}, [client]);
if (!client || !channel) return <div>Loading...</div>;
return (
<Chat client={client}>
<Channel channel={channel}>
<VirtualizedMessageList stickToBottomScrollBehavior="auto" />
<MessageComposer />
</Channel>
</Chat>
);
};Channel.watch()loads the channel and subscribes to its events. It doesn't subscribe to the presence of the channel's users unless you passpresence: true, which a livestream doesn't need.messages.limitsizes the first page only. Later pages, loaded as the user scrolls up, use the paginator'spageSize.stickToBottomScrollBehavior="auto"jumps to each new message instead of scrolling smoothly to it. Smooth scrolling can't keep up with several messages a second.
Capping the messages
Set maxLoadedItems on the channels' message paginators. Once the newest messages loaded exceed the cap, each new message drops the oldest one. To cap every channel, set it in client.config, here through useCreateChatClient:
import { useCreateChatClient } from "stream-chat-react";
export const useLivestreamClient = (
apiKey: string,
userId: string,
userToken: string,
) =>
useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: { id: userId },
config: {
channel: { messagePaginator: { maxLoadedItems: 500 } },
},
});To cap only livestream channels, set it from a setup function. It runs for every channel, so it can check the channel's type:
client.config.setSetupFunction("channel", ({ channel }) => {
if (channel.type !== "livestream") return;
channel.messagePaginator.updateConfig({ maxLoadedItems: 500 });
});There is one channel setup function, so this one replaces any you set before; merge them into one. Setup functions in Configuration explains how they run.
Throttling and the memory cap in Message paginators explains how the cap behaves: which messages it never drops, and when it applies.
Scrolling back
Example
The cap is 500, and a page is 100 messages by default. The user scrolls up and loads two older pages, so the paginator holds 700 messages. The next new message drops the 201 oldest, which are the ones the user is reading. The list jumps.
To avoid this, pause the cap with MessagePaginator.setPruningSuspended(). The SDK's lists don't do it for you. A prune drops the oldest loaded messages, so a pause is needed only while the user reads near them. This component pauses the cap whenever the user has scrolled back, which is simpler but keeps more messages in memory. It keeps the cap while the tab is in the background. Render it inside <Channel/>, next to the list:
import { useEffect } from "react";
import { useMessagePaginator, useStateStore } from "stream-chat-react";
const liveViewSelector = ({ isViewingLive }: { isViewingLive: boolean }) => ({
isViewingLive,
});
export const PausePruningWhileReadingBack = () => {
const messagePaginator = useMessagePaginator();
const { isViewingLive } = useStateStore(
messagePaginator.liveViewState,
liveViewSelector,
);
useEffect(() => {
// A hidden tab isn't viewing live either, but it should keep the cap.
const update = () =>
messagePaginator.setPruningSuspended(!isViewingLive && !document.hidden);
update();
document.addEventListener("visibilitychange", update);
return () => {
document.removeEventListener("visibilitychange", update);
messagePaginator.setPruningSuspended(false);
};
}, [isViewingLive, messagePaginator]);
return null;
};The list sets isViewingLive in channel.messagePaginator.liveViewState. It is true while the user is at the bottom, the newest messages are loaded and the browser tab is visible. A hidden tab counts as not viewing live, so the component also checks document.hidden. It pauses the cap only while the tab is visible and the user is away from the newest messages.
While the cap is paused, the paginator keeps every message. When the user is back at the newest messages, or the tab goes to the background, the next new message drops everything over the cap at once. A user who leaves the tab while scrolled back can return to a shorter list.
Jumping to a message
A jump, for example to a quoted message or a pinned announcement, shows the messages around the target instead of the newest ones. The cap applies to neither the messages around the target nor the newest messages while they're off screen, so the newest messages keep growing as messages arrive. When the user jumps back to the latest message, the next new message brings the newest messages back down to the cap. Throttling and the memory cap in Message paginators explains when the cap applies.
Counting unread messages locally
Turn on channel.readEvents.localUnreadCountEnabled in client.config to count unread messages on this device, without the server. Turning read events off in Read state and unread counts explains what the local count counts, how it resets and who sees it.
What the user sees
Without the local count, the UI has no unread count for the channel. Its item in a channel list has no unread badge. When messages arrive while the user is scrolled up, the message list shows "New Messages!" without a number.
With the local count:
- The channel's item in a channel list shows the unread count, read from
channel.state.read. - The message list's notification counts the messages that arrived while the user was scrolled up, for example "12 new messages".
In both cases the user can't mark a message unread. That action needs the read-events capability, which channels without read events don't give.
Turning off typing, presence and delivery receipts
Typing events are per channel type. Turn off typing_events on the livestream type on the server, and the client sends and shows none.
In the client, a messageComposer setup function can stop the composers of livestream channels from sending typing events:
client.config.setSetupFunction("messageComposer", ({ composer }) => {
if (composer.channel.type !== "livestream") return;
composer.updateConfig({ text: { publishTypingEvents: false } });
});To turn typing off in this client for every channel instead, set channel.typingEvents.enabled: false in client.config. Typing indicators explains the server and client settings.
For presence, watch the channel without presence: true, which is the default, and turn off connect_events on the channel type.
Delivery receipts are per channel type too. When the type's delivery_events setting is on, every viewer reports delivery of the newest message through client.messageDeliveryReporter, about once a second while messages arrive. Turn off delivery_events on the livestream type, or set channel.deliveryEvents.enabled: false in client.config to stop this client from reporting delivery. Message delivery and read status explains the type setting.
Customizing
A compact list
VirtualizedMessageList takes props that make the list denser and cheaper to render:
import { VirtualizedMessageList } from "stream-chat-react";
export const LivestreamMessageList = () => (
<VirtualizedMessageList
additionalVirtuosoProps={{ defaultItemHeight: 40 }}
hideDeletedMessages
showAvatar={false}
stickToBottomScrollBehavior="auto"
/>
);showAvatar={false}hides every avatar. It also takes"incoming", the default, or"outgoing".hideDeletedMessagesleaves deleted messages out, so moderated messages disappear instead of leaving a placeholder.additionalVirtuosoPropspasses options toreact-virtuoso.defaultItemHeightis the height the list assumes for a message it hasn't measured yet. A value close to a real message keeps the scroll bar steady.
Keep a custom message UI light. The list mounts and unmounts messages as the user scrolls, and measures each one. VirtualizedMessageList lists every prop.
Showing the viewer count
channel.state holds watcherCount, the number of users watching the channel. The server sends it with the channel query, and events from the channel can carry an update. ChannelHeader shows it as "N online" next to the member count, but only when the channel has members. Viewers of a livestream usually aren't members. This badge shows the count on its own:
import type { ChannelStateData } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
const watcherCountSelector = ({ watcherCount }: ChannelStateData) => ({
watcherCount,
});
export const ViewerCount = () => {
const channel = useChannel();
const { watcherCount } = useStateStore(channel.state, watcherCountSelector);
return <span className="viewer-count">{watcherCount} watching</span>;
};Render it anywhere inside Channel.
Announcements as pinned messages
Pin a message to keep it reachable, for example a link or the rules of the chat. The channel's pinned messages are in channel.pinnedMessagesPaginator, and jumping to one works as described in Jumping to a message.
The Pin and Unpin actions show only to users with the pin-message capability, so viewers who may not pin don't see them. Pinned messages explains who has it.
Slow mode
Slow mode makes each user wait between messages, which keeps a busy chat readable. Turn it on with Channel.enableSlowMode(seconds). Moderators and admins don't wait. See Slow mode.
Moderation
From the message actions, viewers can flag a message and mute or block its author, and moderators can delete any message. The Flag, Mute and Delete actions show only for users with the matching capability: flag-message, mute-channel, and delete-any-message or delete-own-message. Block has no capability check, so every viewer sees it on other users' messages. Flagging, muting, blocking and bans covers them. Block lists and automated moderation are set on the server, see Moderation.
Reference
- VirtualizedMessageList: every prop of the list.
- Message paginator:
maxLoadedItems,stateThrottleMs,setPruningSuspendedandliveViewState. - Channel and channel state:
watcherCount,markReadLocallyandenableSlowMode. - Configuration tree:
channel.messagePaginator,channel.readEventsandclient.messageDelivery. - Livestream best practices: the server side of a livestream.