Large Room concept
v1's strategy for rooms that scale from 2 people to 10,000+. The SDK splits participants into two tiers β speakers (on-stage; individually tracked) and viewers (audience; count + pull). The split happens at token-mint time via the isViewer claim, not at runtime.
The tier is the *only* knob that controls fan-out scaling. Everything else β events, participant access, enumeration β derives from it. There is no second client-side flag, no "audience event opt-in," no participant-event flooding for large audiences.
participant-joined event to each of the other N receivers is O(NΒ²) β 1,000 joins Γ 1,000 listeners β 1 million messages. Aggregating the audience tier (the unbounded source) collapses it: speakers fire individual events (their count is bounded), viewers contribute only to a coalesced count. A 5-speaker / 10,000-viewer broadcast produces ~5 participant events on each viewer's client, not 10,005.The two tiers
Picked at token-mint time per participant via the token's isViewer claim. The tier decides both how the participant appears to other clients and which surfaces they're accessible through.
| Speaker (on-stage) | Viewer (audience) | |
|---|---|---|
| Token claim | isViewer: false (default) | isViewer: true |
| Per-member events | participant-joined / participant-left (lazy + retroactive on subscribe) | None. No per-member fan-out β only the coalesced count. |
| Sync access | room.remoteParticipants β Map keyed by id | Not tracked client-side as individual objects. |
| Enumeration | Iterate the Map (or getParticipants({ tier: 'speaker' })) | getParticipants({ tier: 'viewer' }) β cursor-paginated |
| Live count | room.remoteParticipants.size (sync) | participant-count-changed event payload { speaker, viewer, total } |
| Publish / subscribe | Allowed (subject to grant) | Calls reject with INVALID_PERMISSIONS β viewers don't have media. |
| Typical use | Hosts, panelists, co-hosts, small all-stage meetings | Webinar attendees, ILS audience, town-hall viewers |
The isViewer claim
A boolean token claim β sibling of grant, not a flag inside it. Default false (speaker). The backend sets it per participant when minting the token.
canSubscribe-only token can be on-stage (rostered listener in a 10-person meeting) or audience (count-only in a 1,000-person ILS) β the difference is whether you want the SDK to roster them and fire individual events. That's the tier choice; it doesn't fall out of the grant. See Authentication β Tier & participant events.Speakers β sync access + individual events
Treated exactly like a regular small-meeting participant. The SDK keeps a synchronous Map keyed by participant id, fires individual lifecycle events (lazy + retroactive for already-present speakers when you subscribe), and exposes the full RemoteParticipant surface.
remoteParticipants was always the v0 sync handle for "everyone except me." In v1 it contains speakers only β viewers are deliberately not tracked client-side as individual objects to keep large rooms scalable. The rename to remoteSpeakers was considered and rejected in favor of preserving muscle memory; the speakers-only meaning is clarified by a callout on the property. See MEETING_DECISIONS_V1.md Decision 17.Viewers β count + paginated pull
No per-member object on the client by default. Two surfaces give you everything you need: a coalesced live count, and a paginated method when you actually need the roster.
Event β participant-count-changed
Broken-out tier counts. Server-coalesced (~250 ms window) so a burst of joins / leaves becomes a small number of updates, not a flood. Always available (lazy β subscribed on first listener). One event covers both an "X attendees" total badge and a "Y speakers Β· Z viewers" breakdown.
room.getParticipants()
Cursor-paginated paginated method. Filter by tier with tier: 'speaker' | 'viewer'; omit for both (speakers first, then viewers). Mirrors the pagination shape of pubsub.getHistory.
| You want⦠| Use |
|---|---|
| Speakers only (sync, hot path) | room.remoteParticipants β Map |
| Speakers only (paginated method form) | room.getParticipants({ tier: 'speaker' }) |
| Viewers only | room.getParticipants({ tier: 'viewer' }) |
| Everyone (unified list) | room.getParticipants() β no filter |
| Total count for "X attendees" badge | participant-count-changed.total OR getParticipants({ limit: 1 }).total |
| Tier breakdown for "5 speakers Β· 1,200 viewers" UI | participant-count-changed ({ speaker, viewer, total }) |
One RemoteParticipant class for both tiers
Viewer-tier instances returned from getParticipants({ tier: 'viewer' }) share the RemoteParticipant class with speakers. The isViewer property is the discriminator. Calling subscribe-style methods on a viewer instance rejects.
ViewerParticipant. Viewer instances carry the same identity (id, displayName, metadata) and the same UI affordances (badges, list rendering). Splitting into two classes would force generic UI code to branch on type, and most surfaces would be identical anyway. The isViewer flag plus permission-aware method rejections is simpler than two parallel hierarchies. See MEETING_DECISIONS_V1.md Decision 17.The audience-token simplification
For broadcasts and webinars where every viewer needs the same access, mint one token without roomId and without participantId β distribute it to every viewer. The SDK generates a unique participantId per session, so each viewer still gets a distinct identity inside the room.
roomId scoping): shorter max exp, and privileged grants (canModerate / canRecord / canHls / canLivestream) are not allowed when roomless. The worst case if a leak happens: anyone can subscribe to any room in your project for the rest of the (short) exp window β not "anyone can moderate every room."Examples
1 Β· Small meeting β everyone on stage
Default case. No tier choice needed β all tokens are isViewer: false.
const room = await VideoSDK.join({ token, roomId, name: 'Alice' });
// Subscribe in response to publish, NOT in participant-joined.
// stream-published fires retroactively for kinds already published when p joined,
// and live for kinds they publish later β one handler covers both.
room.on('participant-joined', (p) => {
p.on('stream-published', ({ kind }) => p.subscribe(kind));
p.on('stream-subscribed', (sub) => {
if (sub.video) document.querySelector('#grid').appendChild(sub.video.createElement());
if (sub.audio) sub.audio.setVolume(0.7);
});
});
// Don't call p.subscribe() inside participant-joined directly β the participant
// may not have published yet (NOT_PUBLISHED rejection). See RemoteParticipant.subscribe.
2 Β· Webinar β mixed tokens
Three token shapes for one room. Speakers and panelists are individually tracked; the audience is count-only.
// Host β speaker + moderator
const hostToken = generateToken({
roomId: 'webinar-2026-05', participantId: 'host-1', name: 'Host',
isViewer: false,
grant: { canPublish: true, canSubscribe: true, canPublishData: true, canSubscribeData: true, canModerate: true, canHls: true },
});
// Panelist β speaker, no moderator privileges
const panelistToken = generateToken({
roomId: 'webinar-2026-05', participantId: 'panelist-jane',
isViewer: false,
grant: { canPublish: true, canSubscribe: true, canPublishData: true, canSubscribeData: true },
});
// Audience β viewer; single reusable token (no participantId, SDK generates one per session)
const audienceToken = generateToken({
roomId: 'webinar-2026-05',
isViewer: true,
grant: { canSubscribe: true, canSubscribeData: true },
});
// Distribute audienceToken to all 1,000+ viewers.
const room = await VideoSDK.join({ token: hostToken });
// Speakers β render tiles
room.on('participant-joined', (p) => renderSpeakerTile(p)); // speakers only
// Audience β count + breakdown
room.on('participant-count-changed', ({ speaker, viewer, total }) => {
ui.attendeesBadge.text(`${total} attendees`);
ui.tierBreakdown.text(`${speaker} speakers Β· ${viewer} viewers`);
});
3 Β· Large ILS audience β 10,000 viewers, one token
Domain-wide audience token + no participantId = one mint covers every viewer. The SDK generates a unique participantId per session, so 10,000 sessions still have 10,000 distinct identities inside the room.
// Backend β mint once, serve via static endpoint
const audienceToken = generateToken({
isViewer: true, // audience tier
grant: { canSubscribe: true, canSubscribeData: true },
// no roomId β domain-wide (any room in the project)
// no participantId β SDK generates one per session
});
// Frontend β every viewer uses the same token
const room = await VideoSDK.join({
token: audienceToken,
roomId: 'town-hall-2026-q3',
name: 'Attendee',
});
room.on('participant-count-changed', ({ viewer }) => {
ui.attendeesBadge.text(`${viewer.toLocaleString()} watching`);
});
participant-joined events + a coalesced participant-count-changed ticker. They never see each other as individual events β that's the whole point of the tier split.4 Β· Host renders the audience roster (paginated)
A moderator UI that lets the host see who's watching. Paginated 50 at a time via getParticipants({ tier: 'viewer' }).
let cursor;
async function loadMoreViewers() {
const page = await room.getParticipants({ tier: 'viewer', limit: 50, cursor });
for (const viewer of page.participants) renderViewerRow(viewer);
cursor = page.nextCursor;
ui.loadMoreButton.disabled = !cursor; // no more pages β disable
ui.totalBadge.text(`${page.total} viewers total`);
}
ui.loadMoreButton.onClick(loadMoreViewers);
loadMoreViewers(); // first page
getParticipants call's total field is the live viewer count at the moment of the call β UIs that just need a snapshot don't have to subscribe to participant-count-changed. If the badge needs to be live, listen to the event instead.5 Β· Find a participant by id, tier unknown
If a moderator action arrives with an unknown participant id (e.g., a moderation webhook), check the speaker Map first (sync), then fall through to a paginated viewer search.
async function findParticipant(id) {
// Speakers β sync, instant
const speaker = room.remoteParticipants.get(id);
if (speaker) return speaker;
// Viewers β paginated; usually you'd index by id server-side instead,
// this is the "I have nothing else" fallback
let cursor;
do {
const page = await room.getParticipants({ tier: 'viewer', limit: 100, cursor });
const found = page.participants.find(p => p.id === id);
if (found) return found;
cursor = page.nextCursor;
} while (cursor);
return null;
}
getParticipants for the rare unknown case. The speaker-Map fast path is fine; the viewer scan is the slow path.6 Β· Promoting an audience member to speaker β coming via Q2
The "audience member raises hand and is promoted to speaker" flow is not yet locked. It maps to Q2 β change grant mid-call in Authentication β Open Questions (because promotion is also a grant change: canPublish: false β true plus an isViewer flip). Three options are on the table β refresh-driven, server-side updateParticipant REST, hybrid. See the open-questions section for the trade-offs.
Errors specific to large rooms
Beyond the standard join rejections, two rejections are tier-specific:
| Code | When |
|---|---|
INVALID_PERMISSIONS | Calling subscribe / publish / requestPublish on a viewer-tier participant (p.isViewer === true). Viewers have no media; these methods are structurally unavailable. |
NOT_PUBLISHED | Calling subscribe() on a speaker who hasn't published the requested kind. Listen for stream-published first (retroactive). See RemoteParticipant.subscribe. |
When to use which tier
| Scenario | Speaker tokens | Viewer tokens |
|---|---|---|
| 1:1 / small team meeting (β€20) | Everyone | β |
| Classroom (small, interactive) | Teacher + students | β |
| Webinar β interactive audience | Host + panelists | All attendees |
| Town hall / all-hands (100s of viewers) | Host + panelists | All attendees |
| Live stream to public webpage (1,000s+) | Host + panelists | All viewers |
See also:
Authentication β Tier & participant events
Authentication β roomId scoping (audience token)
Room β remoteParticipants
Room β getParticipants
Room β participant-count-changed
Types β GetParticipantsOpts
Types β GetParticipantsResult