Audio/Video Track Management in WebRTC
A RTCPeerConnection is only as stable as its tracks. Every camera swap, microphone mute, headset unplug, and OS-level interruption flows through the MediaStreamTrack → RTCRtpSender → RTCRtpTransceiver chain, and getting the wrong API at the wrong moment produces black frames, zombie senders consuming bandwidth, or a renegotiation storm that stalls media for seconds. This guide is part of the Media Handling, Codecs & Bandwidth Estimation guide, and covers the full track lifecycle for production WebRTC: attaching and replacing tracks, controlling transceiver direction, distinguishing mute from disable, handling ended events, and keeping the remote renderer stable through all of it.
The audience here is engineers shipping multi-party calling, screen sharing, or device-switching UIs who have hit the difference between track.enabled = false and sender.replaceTrack(null) the hard way. The implementation goal is a track layer that survives hardware hot-swaps and source changes without dropping streams or forcing a new offer/answer exchange unless one is genuinely required.
Step 1 — Attaching tracks with addTrack and replaceTrack
There are two ways media enters a peer connection, and they have very different renegotiation consequences. pc.addTrack(track, stream) creates a new RTCRtpSender (reusing a recvonly transceiver if one is free, otherwise minting a fresh one) and fires negotiationneeded, requiring a new offer/answer. sender.replaceTrack(newTrack) swaps the media source on an existing sender, keeps the SSRC and the negotiated codec, and does not trigger renegotiation — making it the correct tool for swapping a camera for a screen share, a swap detailed in Replacing Video Tracks Without Renegotiation and, on the capture side, in Screen Sharing & Content Hints.
// First track in: addTrack mints a sender and fires negotiationneeded.
const [videoTrack] = localStream.getVideoTracks();
const sender = pc.addTrack(videoTrack, localStream); // → renegotiation required
// Later source change: replaceTrack reuses the sender, no SDP exchange.
const newTrack = screenStream.getVideoTracks()[0];
await sender.replaceTrack(newTrack); // SSRC preserved, no offer/answer
Attach every track you know about before generating the first offer. Adding tracks one at a time after the connection is live produces a separate negotiationneeded for each, and on slow signaling paths these can collide into glare. Cap getUserMedia constraints early so the encoder never sees a resolution it must immediately scale down, expressing those caps as ideal rather than exact for the reasons set out in exact vs ideal Constraints Without OverconstrainedError; coordinate those caps with your Adaptive Bitrate Streaming in WebRTC targets so the negotiated bitrate ceiling matches the source. A common batching pattern is to acquire camera and microphone in a single getUserMedia call, iterate the resulting MediaStream, and addTrack each track synchronously inside one event-loop turn — most browsers coalesce the resulting negotiationneeded into a single fire, so you exchange one offer/answer for the whole bundle instead of two or three.
Why replaceTrack escapes renegotiation
SDP does not describe your camera. An m-section negotiates a transport, a list of payload types, a set of header extensions, and the SSRCs that will carry them; the MediaStreamTrack is merely the frame source plugged into the encoder sitting behind that description. Swapping one 720p webcam for another changes nothing the remote peer has agreed to, so the specification permits replaceTrack to complete without touching the session description at all. The formal rule is stated in the negative: replaceTrack must reject with InvalidModificationError if honouring the swap would require renegotiation — a different kind, or an encoding configuration the current m-section cannot express. Browsers enforce the kind check strictly and are permissive about almost everything else, absorbing resolution, frame-rate, and aspect-ratio changes by reconfiguring the live encoder rather than rebuilding the session.
That in-place reconfiguration is cheap but not free. A resolution change forces the video encoder to reinitialise and emit an IDR keyframe, and a 720p keyframe is routinely 8–15× the size of a delta frame at the same quality target. On a 1.5 Mbps upstream that single frame can occupy 100–200 ms of send budget, which is why a camera-to-screen swap sometimes shows a brief stutter that looks like packet loss but is really a self-inflicted burst. In a forwarding deployment the same keyframe is what every subscriber needs before it can decode the new resolution, so the swap latency you observe end-to-end is partly governed by the Keyframe Request Strategies in an SFU your server implements, not by the browser call alone.
One more property of the API is easy to misread: the promise returned by replaceTrack resolves once the new source has been attached to the sender, not once the first frame from that source has been encoded and sent. A UI that flips its “sharing screen” indicator on promise resolution will therefore run 1–3 frame intervals ahead of what the remote actually sees. If the indicator needs to be truthful, gate it on outbound-rtp.framesEncoded advancing past the value you sampled immediately before the swap.
A subtlety: replaceTrack(null) removes the media source while leaving the sender, transceiver, and m-line in place. This is the cheapest possible “stop sending video” — the receiver sees the track go silent without any SDP churn. Use it instead of removeTrack() when you intend to resume on the same transceiver shortly. By contrast, removeTrack(sender) clears the sender’s track and fires negotiationneeded; the m-line is flipped to recvonly in the next offer rather than deleted (m-lines are never removed, only recycled), so reaching for removeTrack to “free a slot” is usually a mistake — you pay for renegotiation and the slot lingers anyway. Reserve addTrack/removeTrack for genuine structural changes and let replaceTrack handle the day-to-day source churn.
Step 2 — Controlling transceiver direction
Each RTCRtpTransceiver carries a direction attribute — sendrecv, sendonly, recvonly, or inactive — and the negotiated result is currentDirection. Changing direction fires negotiationneeded; it is how you stop or start a media flow at the SDP level rather than the track level. Set recvonly to keep receiving while you stop sending, or inactive to pause both directions without removing the m-line.
// Explicitly add a transceiver and control its direction.
const transceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
// Stop sending but keep the slot for a later resume → renegotiation.
transceiver.direction = 'recvonly';
// Read what was actually negotiated after the answer is applied.
console.log('negotiated:', transceiver.currentDirection); // e.g. "recvonly"
The distinction that trips people up: direction is your request, currentDirection is the result. If you ask for sendrecv but the remote answers recvonly, your currentDirection becomes sendonly. Always read currentDirection — never direction — when deciding whether media is actually flowing. Reusing transceivers via direction changes is far cheaper than addTrack/removeTrack cycles and avoids the m-line ordering hazards covered in Debugging SDP m-line Mismatches, because the m-line count stays constant.
There is also a sequencing rule worth internalising: a transceiver created recvonly or inactive has no sender track yet, so you must replaceTrack and flip direction to start sending. Flipping direction alone with no track produces a sendrecv m-line that carries no media — the remote negotiates a send slot, allocates a decoder, and waits on an SSRC that never appears. The reverse ordering (set the track first, then the direction) is the safe one. When you pre-allocate transceivers up front — a common pattern for fixed-layout conferences where every participant slot is reserved before anyone joins — initialise them inactive, then promote each to sendrecv with replaceTrack as real media arrives. This keeps the m-line section of every offer identical across participants, which is exactly what makes SDP Renegotiation Without Dropping Streams tractable at scale.
Stopping and recycling transceivers
transceiver.stop() is the fourth direction, and it is one-way. Calling it immediately halts both the sender and the receiver, sets currentDirection to stopped, and fires negotiationneeded; the next offer carries that m-section with a port of 0, which tells the remote the slot is rejected. Chrome shipped the spec-compliant stop() in M88 — before that the only way to retire a transceiver was direction = 'inactive', which is why a lot of older conferencing code has no stop path at all. The important consequence is that a stopped transceiver can never be revived: its mid is retired, its sender is inert, and replaceTrack on it rejects.
The m-line itself, however, does not disappear. JSEP permits a rejected m-section to be recycled — a later addTrack may reuse that index for a brand-new transceiver, which receives a fresh mid and must be re-added to the BUNDLE group. Browsers only recycle on an offer they generate themselves, and only when the section has been rejected in both directions, so a slot you stopped mid-call is typically reused a renegotiation or two later rather than instantly. Until it is, the dead section still costs roughly a dozen SDP lines.
That accounting matters for long-running rooms. A conference that stops and re-adds transceivers as participants come and go accumulates rejected sections, and an offer carrying 30–40 of them runs to several kilobytes — enough that setRemoteDescription parsing becomes measurable and the sub-10 ms signaling delivery budget you assume over a WebSocket starts to include real serialisation time. The practical policy: use direction flips and replaceTrack(null) for anyone who might return within the session, and reserve stop() for participants who have definitively left, so recycling has a chance to keep the m-line count near the room’s steady-state size.
Step 3 — Mute, enabled, and what the remote actually sees
There are three separate “off” states and conflating them causes most track bugs — Muting Tracks vs Stopping Them works through the same decision from the UI side. track.enabled = false is a local gate: the track stays live, but the browser replaces its output with black frames (video) or silence (audio) and keeps sending RTP — bandwidth is not freed, and the remote sees a frozen-to-black image, not a paused stream. The muted property is read-only and reflects the source being unable to produce data (OS took the mic, camera in use by another app); you cannot set it. To genuinely stop sending and free bandwidth, use replaceTrack(null) or flip the transceiver to recvonly/inactive.
// Local UI mute: cheap, instant, but keeps the RTP stream alive (black/silence).
function setMuted(track, muted) {
track.enabled = !muted; // remote sees black frames / silence, bandwidth unchanged
}
// True bandwidth release on the same sender, no renegotiation:
async function stopSendingVideo(sender) {
await sender.replaceTrack(null); // RTP stops; transceiver and m-line remain
}
The rule of thumb: enabled for a transient mic-mute button where you want instant resume and don’t care about the few hundred kbps of black-frame RTP; replaceTrack(null) when the stream will be off long enough that wasting bandwidth matters, or when you want the remote’s bitrate estimator to recover that capacity. The remote side detects enabled = false only as a content change (black/silent), while replaceTrack(null) surfaces as the receiver track muting.
This distinction matters for congestion control as much as for UX. When ten participants each leave a muted-but-enabled video track alive, the SFU is still forwarding ten black-frame streams, and the per-subscriber estimator never reclaims that headroom — the symptom is an availableOutgoingBitrate that stays artificially low even though nobody is actually transmitting useful video. Routing real mutes through replaceTrack(null) or inactive lets the estimator recover the capacity within a getStats poll or two (sampling at the usual 1 s interval). On the audio side the calculus differs: an enabled = false audio track with Opus DTX still sends comfort-noise frames at a handful of kbps, which is negligible, so enabled is almost always the right mute for microphones and replaceTrack(null) the right one for cameras. Document this split in your call-control layer so a “mute” button maps to the correct primitive per track kind.
Track swaps on a simulcast sender
Everything above changes shape once the sender carries multiple encodings. RTCRtpSender.getParameters().encodings is a property of the sender, not of the track, so it survives a swap untouched — the rid list, scaleResolutionDownBy, maxBitrate, and the active flags all persist across replaceTrack. That persistence is deliberate (the encodings are part of what was negotiated, and setParameters may not add, remove, or reorder them), but it produces two behaviours worth designing around when you follow the layer configuration in Simulcast & SVC Implementation.
First, the scale factors are ratios, not resolutions. Layers configured at 1/2 and 1/4 scale against a 1280×720 camera produce 640×360 and 320×180; swap in a 1920×1080 screen capture and the same untouched factors now yield 960×540 and 480×270. The low layer has grown by more than double in pixel count, and its maxBitrate — sized for a thumbnail — is now badly under-provisioned for it, so the smallest layer degrades hardest exactly when the content is shared text. Recompute scaleResolutionDownBy in the same turn as the swap.
Second, active flags are sticky. If congestion control disabled your top layer during a bad minute and the user then switches cameras, the new source inherits a sender whose high encoding is still active: false, and it silently publishes only two layers forever after. This is the “stale encoding state after a source swap” failure: subscribers report that one participant is permanently soft while getStats shows a healthy availableOutgoingBitrate. The diagnosis is a single read of sender.getParameters().encodings.map(e => e.active) after the swap; the fix is to restore the intended encoding state as part of the swap routine rather than leaving it to whatever the estimator last decided.
Third, contentHint lives on the track, so it does not survive a swap either. A screen-share track handed to a sender without track.contentHint = 'detail' inherits motion-biased degradation and the encoder trades resolution for frame rate — the classic symptom of unreadable shared text.
// Re-derive layer geometry and restore intent whenever the source changes.
async function swapVideoSource(sender, nextTrack, isScreen) {
nextTrack.contentHint = isScreen ? 'detail' : 'motion'; // hint rides on the track
await sender.replaceTrack(nextTrack);
const params = sender.getParameters();
const { height = 720 } = nextTrack.getSettings(); // actual source geometry
params.encodings.forEach((enc, i) => {
enc.active = true; // clear stale congestion state
enc.scaleResolutionDownBy = [1, 2, 4][i] * (height / 720); // keep layers ~720/360/180
});
await sender.setParameters(params); // count and rids unchanged, so no renegotiation
}
Step 4 — Verification: ended events and renderer stability
MediaStreamTrack fires ended when its source terminates permanently — the user unplugs a USB camera, the OS revokes the device, or the screen-share picker is dismissed; the reacquisition half of that story is covered in Handling Device Hotplug & Permission Changes. Unlike muted, ended is terminal: the track will never produce frames again, and ignoring it leaves a sender transmitting nothing while the remote stares at a frozen last frame. Bind the listener at attach time, push a signaling notification, and attempt reacquisition.
function watchTrack(track, sender) {
track.addEventListener('ended', async () => {
console.warn(`track ${track.id} ended (source gone)`);
// Free the RTP stream immediately so the remote stops waiting on a dead source.
await sender.replaceTrack(null);
// Then attempt reacquisition or signal the peer to update its UI.
});
}
On the remote, renderer stability hinges on never reassigning videoElement.srcObject when only the track changed. Because replaceTrack keeps the same SSRC, the remote MediaStreamTrack object stays identical — the <video> element keeps rendering through the swap with no flicker. Only ontrack (a genuinely new receiver) should cause you to touch srcObject. Verify the swap worked by polling getStats() for outbound-rtp.framesEncoded climbing and track.muted === false on the receiver. Frozen remote video despite a successful local replaceTrack almost always means the renderer rebound srcObject and lost the decode pipeline, or the encoder is starved by Bandwidth Estimation & Congestion Control backpressure rather than a track fault.
Separating a dead source from a throttled encoder
ended covers sources that terminate cleanly, but the nastier failure is a source that stays live and simply stops producing frames — a laptop lid closing on an external camera, a virtual-camera driver crashing behind a still image, a screen capture whose window was minimised. readyState remains 'live', muted remains false, no event fires, and the sender dutifully re-encodes the same frozen picture. Distinguishing this from an encoder that congestion control has throttled to a crawl takes two different stats objects: media-source describes what the capture pipeline is delivering, outbound-rtp describes what the encoder is emitting. Frames stalled at the source with the encoder idle means a dead capture; frames arriving at the source but few leaving the encoder means bandwidth or CPU pressure, and qualityLimitationReason names which.
// Poll at the usual 1 s cadence and classify the two stalls differently.
let prev = { src: 0, enc: 0 };
setInterval(async () => {
const now = { src: 0, enc: 0, reason: 'none' };
(await pc.getStats()).forEach((s) => {
if (s.type === 'media-source' && s.kind === 'video') now.src = s.frames ?? 0;
if (s.type === 'outbound-rtp' && s.kind === 'video') {
now.enc = s.framesEncoded ?? 0;
now.reason = s.qualityLimitationReason; // 'bandwidth' | 'cpu' | 'none'
}
});
const srcDelta = now.src - prev.src, encDelta = now.enc - prev.enc;
if (srcDelta === 0) console.warn('capture stalled — reacquire the device');
else if (encDelta === 0) console.warn('encoder stalled, limited by', now.reason);
prev = now;
}, 1000);
Two consecutive polls with srcDelta === 0 are enough to act on: at any sane capture rate a working source produces at least 5 frames per second, so a full second of nothing is not jitter. The recovery is a fresh getUserMedia followed by replaceTrack on the same sender, which keeps the SSRC and spares the remote a renderer rebind. When you need to confirm the diagnosis after the fact rather than in code, the same two counters are plotted side by side in the dumps described in Reading chrome://webrtc-internals Dumps — a flat framesEncoded line with a flat source line above it is unmistakable.
Edge Cases & Browser Quirks
- Chrome:
replaceTrack()resolves even when the new track’s resolution differs wildly from the old; the encoder reconfigures on the fly but may emit a keyframe burst that briefly spikes bitrate. Pre-align source capabilities with your VP8 vs H.264 vs AV1 Codec Selection profile to avoid decoder reinit on the remote. - Firefox: Validates the replacement track’s kind strictly — replacing a video track with an audio track rejects with
TypeErrorsynchronously. Firefox also historically firednegotiationneededmore eagerly onaddTransceiver; guard your handler against re-entrancy. - Safari/iOS 17+: A
MediaStreamTrackwhose source is a backgrounded tab transitions tomuted(notended) and back to live on foreground; do not tear down the sender onmuted.setSinkIdis supported from Safari 17 but feature-detect, and on iOS the camera track canendsilently when another app grabs the camera —endedis your only signal. - All browsers:
track.enabled = falsedoes not reliably zero out outbound bitrate; some versions keep sending ~30 kbps of black-frame RTP. Treatenabledas a content gate, not a bandwidth control. - Android Chrome & WebView: the camera is released when the host activity is paused, and the track goes to
endedrather thanmuted— the exact inverse of iOS Safari’s backgrounding behaviour. Code that only handles one of the two will either tear down a recoverable session on iOS or leave a permanently dead sender on Android. Branch on the event, never on a user-agent sniff. negotiationneededtiming: all three engines queue the event and fire it at a microtask checkpoint, so several synchronousaddTrackcalls coalesce into one. Anythingawaited between them breaks the coalescing and yields one event per track. Firefox will also re-fire the event whensignalingStatereturns tostablewith changes still pending, so a handler that assumes it runs once per user action needs to be idempotent.setParametersscope: no browser lets you add, remove, or reorderencodings, or change arid, after negotiation — those are fixed by thesendEncodingsyou passed toaddTransceiver. Onlyactive,maxBitrate,maxFramerate,scaleResolutionDownBy, andpriorityare mutable, and Chrome rejects the whole call withInvalidModificationErrorif the encodings array length differs from what it handed you.- Audio device swaps: replacing a microphone track from a second
getUserMediacall restarts echo cancellation convergence, so the first 200–400 ms after the swap can leak a little of the far end back. Capturing both microphones from one call and swapping between them avoids the reconvergence, as covered alongside the rest of the capture chain in Audio Processing & Opus Tuning.
Common Implementation Mistakes
- Using
track.enabled = falsefor source switching. This shows the remote black frames instead of new video. Fix: usereplaceTrack(newTrack)for any source change; reserveenabledfor mute UI. - Reading
directioninstead ofcurrentDirection. Your code believes it is sending while the negotiated result isrecvonly. Fix: branch oncurrentDirectionafter each answer is applied. - Reassigning
srcObjecton every track change. Rebinding the element drops the decode pipeline and flickers the remote view. Fix: only setsrcObjectfromontrack; letreplaceTrackflow through transparently. - Ignoring
ended, creating zombie senders. A sender on a dead source keeps an SSRC alive and freezes the remote on its last frame. Fix: bindendedat attach time and callreplaceTrack(null). - Calling
removeTrackwhen you mean to pause.removeTrackfiresnegotiationneededand a full SDP exchange. Fix: usereplaceTrack(null)or flip the transceiver toinactivefor resumable pauses. - Duplicate senders after a reconnect. Re-running your join routine against a peer connection that already has senders adds a second video m-line, and the upstream encoder load and bitrate double while both SSRCs flow. Fix: before
addTrack, look for an existing sender of the right kind withpc.getSenders().find(s => s.track?.kind === 'video')and preferreplaceTrackon it. - Stopping the whole
MediaStreamto mute one track. Iteratingstream.getTracks()and callingstop()kills the microphone alongside the camera, and the mic cannot be revived without a freshgetUserMedia— which on Safari re-prompts if the permission was one-shot. Fix: act on the single track you mean, and preferreplaceTrack(null)when resume is likely. - Leaving
contentHintunset after swapping in a screen capture. The hint is a property of the track, so every swap discards it and the encoder reverts to motion-biased degradation. Fix: setcontentHinton the new track before you pass it toreplaceTrack.
FAQ
When should I use replaceTrack() versus toggling track.enabled?
Use replaceTrack() for any physical source swap (camera change, camera-to-screen) because it preserves the SSRC and skips SDP renegotiation entirely. Use track.enabled only for a transient mute where you want instant resume and accept that black-frame RTP keeps flowing. For a true bandwidth-freeing stop, replaceTrack(null) is the correct middle ground.
Does adding a track require an ICE restart?
No. addTrack fires negotiationneeded, which needs a new offer/answer exchange but reuses the existing ICE and DTLS transport. Reserve ICE restarts (createOffer({ iceRestart: true })) for genuine network topology changes or a connectionState of failed — not for track or direction changes.
Why does the remote video freeze after a successful local replaceTrack?
Almost always the remote renderer rebound srcObject when it didn’t need to, or the encoder is starved by congestion control rather than the swap failing. Confirm outbound-rtp.framesEncoded is climbing locally; if it is, the fault is on the remote render path, not the track layer.
What is the difference between muted and ended?
muted is a recoverable, read-only state meaning the source temporarily cannot produce frames (OS grabbed the device, tab backgrounded on iOS). ended is terminal — the source is gone for good. Never tear down a sender on muted; always handle ended.
Does replaceTrack preserve my simulcast layers?
The encodings do survive — rids, scale factors, bitrate caps, and active flags all belong to the sender and are untouched by the swap. What does not survive is their appropriateness: scaleResolutionDownBy is a ratio against the new source, so a 1080p screen capture fed into factors tuned for a 720p camera produces layers roughly 50% larger in each dimension than intended, against unchanged bitrate caps. Recompute the factors and reassert active: true in the same routine that performs the swap.
Should I stop a transceiver or just set it inactive?
Set it inactive whenever the participant or source might come back in this session — the m-line, mid, and negotiated codecs stay valid, and resuming costs one offer/answer. Use stop() only for a definitive departure, because a stopped transceiver is unrecoverable and its m-section lingers as a rejected slot until the browser recycles the index on a later offer of its own.
Why does my negotiationneeded handler fire twice for one camera switch?
Almost always because something is awaited between the calls that mutate the connection. The event is queued and delivered at a microtask checkpoint, so synchronous mutations coalesce into one fire while awaited ones each get their own. Make the handler idempotent — check pc.signalingState === 'stable' before creating an offer and drop the call otherwise — rather than trying to guarantee a single event.
Related: return to the Media Handling, Codecs & Bandwidth Estimation guide, or dive into Managing Audio Focus & Echo Cancellation Across Devices, Replacing Video Tracks Without Renegotiation, and SDP Renegotiation Without Dropping Streams.