Who owns which objects, what disconnect() and dispose() each release, and
the ordering the SDK guarantees while a Room tears down.
The developer docs cover usage, and
FRAME_CONTRACT.md covers frame delivery.
Local tracks are the only SDK objects you construct. Everything else is handed
to you by the Room that produced it: participants, remote tracks, and frames()
iterators, all released by room.dispose(). Local tracks stay yours. They have
no release method, and disposing the Room does not touch them.
Each Room caches its remote track wrappers by Track SID, so a trackSubscribed
event and a read of publication.track give you the same object, and the
frames() iterator running on it is the one the SDK ends. The cache entry is
dropped when the track is unsubscribed or the Room tears down, so a read after
that point mints a fresh wrapper. Two Rooms subscribed to the same publication
each get their own wrapper.
participant.videoTracks and participant.audioTracks hold publications, not
tracks; publication.track is what resolves through the cache.
room.disconnect() leaves the session and returns right away; the
disconnected event follows. The Room object stays readable, but it still holds
the native resources behind it, so the process does not exit on its own.
room.dispose() releases everything the Room owns: participant wrappers, active
frames() iterators, the native Room, and every listener. It disconnects first if the
session is still up, so dispose() on its own is a complete teardown. A
disposed Room is no longer usable.
Call either one more than once, in either order, without harm.
dispose() also ends the Room's event delivery. It closes the event queue
before it disconnects, so an application that calls dispose() directly never
receives disconnected, and a disconnected raised by an earlier
disconnect() is discarded if it has not been delivered yet.
Two shutdown shapes follow from that. To act on the event, dispose from the handler:
room.on('disconnected', () => {
room.dispose();
});
room.disconnect();
To shut down without waiting, dispose directly:
process.on('SIGINT', () => {
room.dispose();
});
The second reports no disconnected. Since dispose() is also what lets the
process exit, it is the call to make from a signal handler or a timeout.
Calling disconnect() or dispose() from inside the disconnected handler is
supported, as it is from any other Room or Participant event handler. You do not
need to defer the call to a later tick. One consequence to plan for: disposing
from a handler discards Room events still queued behind the one you are
handling, such as a
participantDisconnected that a remotely ended Room raised at the same moment.
If your application needs those events, let the handler return and dispose on a
later tick.
A handler that throws stops that delivery, the way a throwing EventEmitter
listener stops an emit. The exception reaches the process as an
uncaughtException, and the events queued behind it are delivered afterward.
A receiver is the active frames() iterator on a track: the single consumer
pulling decoded frames from it. track.frames() starts one, and a track
supports one at a time.
A receiver ends on its own when the track is unsubscribed or the Room
disconnects, so a for await loop exits instead of hanging. Leaving the loop
ends it early.
Once a receiver ends, its frames stop: the SDK unregisters the sink and closes it, so a frame in flight on a decoder thread is dropped, and frames already queued for the JavaScript thread are discarded rather than delivered. This holds after a remote Room end as well, when there is no longer a media track to unregister from.
Ending a receiver is idempotent, and safe from inside a trackUnsubscribed
handler: the SDK ends the receiver itself as soon as that handler returns.
track.getStats() keeps reporting the final counts after a receiver ends, for
as long as you hold that track wrapper. A wrapper minted after the cache entry
was dropped has no history and reports zeros.
Two limits apply to that final report today. The queueDepth it carries can be
nonzero although nothing is queued any more, and frames still held at the native
boundary when the receiver ended are not counted in framesDropped.
The SDK guarantees this ordering during teardown:
trackUnsubscribed arrives before the SDK ends that track's receiver, so the
handler still sees a live track. This holds while the Room is up. If the Room
ends at the same moment, the disconnected path may end every receiver and
drop the wrapper cache first, and the trackUnsubscribed that follows carries
a fresh wrapper with no receiver and zeroed stats: Room events and
per-participant events ride independent queues with no ordering between them.participantDisconnected arrives after every trackUnsubscribed raised for
that participant's tracks.disconnected arrives after every active receiver has ended, so a for await
loop is guaranteed to exit rather than hang on a dead Room. The handler still
runs first: ending a receiver resolves its parked next() promise, and the
loop resumes on a later microtask. Results a loop tallies as it finishes are
not final while the disconnected handler is running.A track supports one receiver at a time, and frames() throws while one is
active. Ending a receiver releases the track for the next one.