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 MediaStreamTrackRTCRtpSenderRTCRtpTransceiver 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.

Track lifecycle and the sender/receiver/transceiver triad A live track can transition to muted and back, or terminate at ended; a transceiver pairs one sender and one receiver and carries a direction attribute. MediaStreamTrack lifecycle live frames flowing muted source paused ended terminal mute unmute Transceiver pairs one sender + one receiver RTCRtpTransceiver (direction: sendrecv) RTCRtpSender replaceTrack() outbound, keeps SSRC RTCRtpReceiver remote track fires ontrack
Track states and the transceiver that binds a sender to a receiver.

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
addTrack round trip versus replaceTrack local swap The upper sequence shows addTrack firing negotiationneeded and an offer travelling to the remote peer and an answer returning through the signaling server; the lower sequence shows replaceTrack swapping the source on the existing sender with no signaling traffic at all. addTrack() — negotiationneeded, one offer/answer round trip Local peer Signaling Remote peer offer: new m=video sendrecv forward offer answer + new SSRC accepted setRemoteDescription, media starts replaceTrack() — source swap stays inside the existing sender Local peer Signaling (idle) Remote peer sender.replaceTrack(next) SSRC, codec, m-line unchanged no offer/answer RTP continues on the same SSRC — no ontrack fires
The same visible outcome — new video on the wire — with and without a signaling round trip.

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 one SDP line a direction change rewrites An annotated m-section lists the media line, mid, rtpmap, direction attribute and ssrc; only the direction attribute changes between sendrecv, recvonly and inactive, and the negotiated currentDirection is the intersection of the local request and the remote answer. One m-section — only the direction attribute is rewritten m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:1 a=rtpmap:96 VP8/90000 a=sendrecv <- the only line direction touches a=rtcp-fb:96 transport-cc a=ssrc:1481275 cname:local-cam mid and ssrc survive every direction flip direction = sendrecv encoder active, decoder active direction = recvonly send half parked, slot reserved direction = inactive both halves paused, m-line kept Request vs negotiated result you set direction sendrecv remote answers recvonly currentDirection sendonly Branch on currentDirection: direction is what you asked for, not what is on the wire.
A direction change rewrites one attribute line; the negotiated result can still differ from the request.

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
}
Four ways to stop sending, compared Rows for track.enabled false, replaceTrack null, transceiver direction inactive and track.stop, scored against whether RTP keeps flowing, whether bandwidth is released, whether renegotiation is required, what the remote peer observes and whether resume is instant. Which "off" primitive actually frees bandwidth primitive RTP sent bandwidth freed renegotiation remote sees resume cost track.enabled = false yes, ~30 kbps no none black / silence instant sender.replaceTrack(null) no yes none receiver mutes one call direction = inactive no yes offer/answer m-line paused one round trip track.stop() no yes none frozen last frame re-capture device Only the rows that stop RTP let the remote estimator reclaim headroom, typically within one or two 1 s getStats polls.
The four "off" primitives differ mainly in who pays: bandwidth, signaling, or resume latency.

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

Common Implementation Mistakes

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.