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.

Why two tiers, not one. A naive room with N participants firing a 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 claimisViewer: false (default)isViewer: true
Per-member eventsparticipant-joined / participant-left (lazy + retroactive on subscribe)None. No per-member fan-out β€” only the coalesced count.
Sync accessroom.remoteParticipants β€” Map keyed by idNot tracked client-side as individual objects.
EnumerationIterate the Map (or getParticipants({ tier: 'speaker' }))getParticipants({ tier: 'viewer' }) β€” cursor-paginated
Live countroom.remoteParticipants.size (sync)participant-count-changed event payload { speaker, viewer, total }
Publish / subscribeAllowed (subject to grant)Calls reject with INVALID_PERMISSIONS β€” viewers don't have media.
Typical useHosts, panelists, co-hosts, small all-stage meetingsWebinar 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.

// JWT payload β€” sibling of `grant` { "roomId": "webinar-2026-05", "participantId": "viewer-83", "isViewer": true, // audience tier "grant": { "canSubscribe": true, "canSubscribeData": true }, … }
Tier is independent of capability. A 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.

room.remoteParticipants: Map<string, RemoteParticipant> room.on('participant-joined', (p) => void); // fires retroactively + live; speakers only room.on('participant-left', (p) => void);
The Map name is preserved for v0 familiarity. 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

room.on('participant-count-changed', ({ speaker, viewer, total }) => void)

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.

getParticipants(opts?: GetParticipantsOpts): Promise<GetParticipantsResult>
interface GetParticipantsOpts { tier?: 'speaker' | 'viewer'; // omit = both (speakers first, then viewers) limit?: number; // page size; default 50, max 100 (clamped) cursor?: string; // opaque token from a previous call } interface GetParticipantsResult { participants: RemoteParticipant[]; nextCursor?: string; // present iff more pages total: number; // total in this filter β€” avoid parallel count listen }
You want…Use
Speakers only (sync, hot path)room.remoteParticipants β€” Map
Speakers only (paginated method form)room.getParticipants({ tier: 'speaker' })
Viewers onlyroom.getParticipants({ tier: 'viewer' })
Everyone (unified list)room.getParticipants() β€” no filter
Total count for "X attendees" badgeparticipant-count-changed.total OR getParticipants({ limit: 1 }).total
Tier breakdown for "5 speakers Β· 1,200 viewers" UIparticipant-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.

p.isViewer: boolean; // readonly β€” tier discriminator p.subscribe(MediaKind.Video); // REJECTS with INVALID_PERMISSIONS if p.isViewer p.publishVideo({...}); // REJECTS with INVALID_PERMISSIONS if p.isViewer p.requestPublishVideo(); // REJECTS with INVALID_PERMISSIONS if p.isViewer
Why one class instead of 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.

// Backend β€” single mint, distribute to all viewers const audienceToken = generateToken({ isViewer: true, // audience tier grant: { canSubscribe: true, canSubscribeData: true } // no roomId β†’ domain-wide (any room in this project) // no participantId β†’ SDK generates a unique id per session });
Bounded blast radius. Domain-wide tokens are bounded by guardrails (see Authentication β†’ 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.

Example β€” render every participant
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.

Example β€” backend mints three tokens
// 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.
Example β€” host renders speakers + audience-count badge
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.

Example β€” single-mint audience token
// 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`);
});
The audience never floods anyone's socket. Each of the 10,000 viewers receives only: the 5 speakers' 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' }).

Example β€” infinite-scroll viewer list
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
Counter without a parallel listen. The first 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.

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.

Example β€” find-by-id across both tiers
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;
}
Audience scans don't scale. Scanning a 10,000-viewer audience client-side to find an id is an anti-pattern β€” index by id in your backend instead, and only fall through to 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.

Not yet locked. Until Q2 lands, this flow doesn't have a v1-canonical API. Customers building this pattern today should plan to migrate when Q2 ships β€” the underlying capability is straightforward (mint a new token, rejoin), the open question is how the SDK surfaces the transition without a full rejoin.

Errors specific to large rooms

Beyond the standard join rejections, two rejections are tier-specific:

CodeWhen
INVALID_PERMISSIONSCalling subscribe / publish / requestPublish on a viewer-tier participant (p.isViewer === true). Viewers have no media; these methods are structurally unavailable.
NOT_PUBLISHEDCalling 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

ScenarioSpeaker tokensViewer tokens
1:1 / small team meeting (≀20)Everyoneβ€”
Classroom (small, interactive)Teacher + studentsβ€”
Webinar β€” interactive audienceHost + panelistsAll attendees
Town hall / all-hands (100s of viewers)Host + panelistsAll attendees
Live stream to public webpage (1,000s+)Host + panelistsAll viewers

See also: Authentication β†’ Tier & participant events Authentication β†’ roomId scoping (audience token) Room β†’ remoteParticipants Room β†’ getParticipants Room β†’ participant-count-changed Types β†’ GetParticipantsOpts Types β†’ GetParticipantsResult