Muting Tracks vs Stopping Them
A mute button looks like a one-line feature until a user reports that the camera indicator light stayed on after they muted, or that unmuting took two seconds, or that the far side saw a frozen face instead of a black tile. This guide is part of the Audio/Video Track Management guide, and it settles one decision: which of track.enabled = false, sender.replaceTrack(null), track.stop(), and a change to transceiver.direction belongs behind that button — given that exactly one of them releases the capture device, exactly one of them forces an offer/answer exchange, and their resume costs differ by three orders of magnitude.
Context & Trade-offs
enabled is the only one of the four that is an application-controlled gate on the track’s output. Setting track.enabled = false leaves readyState at 'live' and leaves the source untouched: the OS still owns the camera, the hardware indicator LED stays lit, the capture pipeline keeps delivering frames, and the encoder keeps encoding them — it just encodes silence or black. That has a measurable cost on the wire. A black 720p VP8 stream still compresses to roughly 10–30 kbps of keepalive traffic, and a disabled microphone track keeps paying its full Opus rate of 24–32 kbps unless discontinuous transmission is negotiated, at which point comfort-noise updates collapse it to a few kbps. If your product mutes hundreds of participants at once, negotiating DTX as described in Munging SDP to Prefer Opus DTX is worth more than any change to the mute logic itself.
Note that track.muted is not the write side of this. muted is read-only and reflects the source’s own state — the OS pausing capture, a hardware mute switch, an interrupting phone call. You never set it; you listen for it. Confusing the two is the most common reason a mute button appears to work locally and does nothing at all on the wire.
sender.replaceTrack(null) detaches the media source from the sender. RTP stops completely, the transceiver and its m-line survive intact, the SSRC is preserved, and no negotiationneeded fires. The device stays open and the LED stays on, because the track itself is still live — you have simply stopped feeding it to the encoder. track.stop() is the only call in the set that reaches the hardware: readyState becomes 'ended', the browser releases the device, and the LED goes out. It is also irreversible. sender.track remains non-null but ended, and unmuting means a fresh getUserMedia() call — typically 100–500 ms, longer on machines where the camera driver is slow to re-initialise — followed by a replaceTrack() onto the same sender. Finally, assigning transceiver.direction = 'inactive' or 'recvonly' is an SDP-level operation: it fires negotiationneeded, costs a full round trip of 200–800 ms before media can resume, and on the way back the browser may allocate a new SSRC, which resets the remote’s jitter buffer and decoder state.
There is a fifth option that is easy to overlook: setting encodings[0].active = false through sender.setParameters(). It stops the RTP for that encoding without ending the track and without an SDP exchange, and unlike replaceTrack(null) it works per simulcast layer, so you can shut off the high layer of a mostly idle participant while leaving the thumbnail running. The mechanics of editing sender parameters safely are covered in Reacting to Bandwidth Drops with RTCRtpSender Parameters; for a plain mute button it is overkill, but for server-driven bandwidth policy it is the right lever.
The other half of the decision is what the remote actually perceives. enabled = false is invisible to the peer connection: real RTP keeps arriving, so the remote track never fires mute, and the far-side <video> renders genuine black pixels. Stopping RTP — via stop(), replaceTrack(null), or an inactive direction — makes the remote receiver’s track fire mute after roughly a second of silence, and the video element freezes on the last decoded frame rather than going black. Neither behaviour tells the remote why, so a mute state that users can see must travel over your own signaling channel as explicit application state. Never infer another participant’s mute state from media statistics.
Minimal Runnable Implementation
// Mute controller: soft mute keeps the SDP, the transceiver and the SSRC intact.
const muteState = { audio: false, video: false, cameraReleased: false };
function senderFor(pc, kind) {
return pc.getSenders().find(s => s.track && s.track.kind === kind);
}
function setSoftMuted(pc, kind, muted) {
const sender = senderFor(pc, kind);
if (!sender) return; // nothing attached yet — nothing to gate
sender.track.enabled = !muted; // sink gate; readyState stays 'live'
muteState[kind] = muted;
// RTP keeps flowing (black frames / silence), so the peer learns nothing from
// the media itself. Publish the intent over signaling for the remote UI.
signaling.send({ type: 'mute-state', audio: muteState.audio, video: muteState.video });
}
// Hard camera release: the ONLY path that turns the hardware indicator off.
async function setCameraReleased(pc, release) {
const sender = senderFor(pc, 'video');
if (!sender) return;
if (release) {
sender.track.stop(); // readyState → 'ended', LED off, RTP stops
// Deliberately NOT touching transceiver.direction: that would fire
// negotiationneeded and cost a 200–800 ms offer/answer round trip.
} else {
// Irreversible stop means re-acquiring the device before we can resume.
const fresh = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 } }
});
await sender.replaceTrack(fresh.getVideoTracks()[0]); // same SSRC, no SDP exchange
}
muteState.cameraReleased = release;
signaling.send({ type: 'camera', released: release });
}
Two details in that code are load-bearing. First, senderFor reads s.track, not s.track?.kind alone, because after replaceTrack(null) the sender still exists with a null track and would otherwise be skipped silently. Second, the resume path replaces onto the existing sender rather than calling addTrack, which keeps the swap free of renegotiation exactly as described in Replacing Video Tracks Without Renegotiation.
Reproduction Steps & Debugging Log Patterns
- Establish a connected call with one audio and one video sender, and attach a
pc.addEventListener('negotiationneeded', ...)logger so any accidental SDP churn is visible. - Call
setSoftMuted(pc, 'video', true)and pollpc.getStats()at 1 s intervals, printingoutbound-rtp.bytesSentdeltas. The slope should fall to a trickle but never reach zero. - Print
track.readyState,track.enabledandtrack.mutedon the same tick — you are verifying that onlyenabledmoved. - Call
setCameraReleased(pc, true)and watch both the physical indicator LED and the remote track’smuteevent. - As a control, set
transceiver.direction = 'inactive'by hand and countnegotiationneededfirings: exactly one, versus zero for every other step.
// Healthy soft-mute trace (1 s getStats polling):
// [t+0s] enabled=false readyState=live muted=false bytesSent Δ 41200
// [t+1s] enabled=false readyState=live muted=false bytesSent Δ 2900 // black frames
// [t+2s] enabled=false readyState=live muted=false bytesSent Δ 2750
// (no "negotiationneeded" line anywhere)
// Hard release trace:
// [t+0s] track.stop() → readyState=ended LED off
// [t+1s] bytesSent Δ 0
// remote: video track 'mute' event fired, element frozen on last frame
// Direction change trace — the expensive path:
// negotiationneeded fired (1)
// setLocalDescription → a=inactive on m=video
// resume required a full offer/answer before any frame arrived
If bytesSent drops to a hard zero the moment you set enabled = false, you are not looking at a soft mute — check whether some other layer also called replaceTrack(null). Conversely, if it stays at the full pre-mute rate on an audio sender, DTX was never negotiated. Reading these slopes correctly is the same skill used in Interpreting getStats() for Congestion Signals.
Common Implementation Mistakes
- Shipping
enabled = falseas a privacy feature. The camera stays powered and the indicator light stays lit, so users reasonably conclude they are still being recorded. Fix: for anything labelled “camera off”, calltrack.stop()and re-acquire on resume. - Calling
stop()on every mute press. Each unmute then pays agetUserMedia()of 100–500 ms plus a permission-prompt risk if the user later revokes access mid-call; on some hardware the LED visibly blinks. Fix: reservestop()for explicit camera-off and long idle periods. - Flipping
transceiver.directionto mute. This firesnegotiationneeded, and if both peers mute at once you have manufactured a glare condition on top of a UI event. Fix: leave direction alone unless the participant’s role genuinely changed, and handle the negotiation as described in SDP Renegotiation Without Dropping Streams. - Writing to
track.muted. It is read-only and reflects the source, not your intent. Fix: writeenabled, and treatmute/unmuteevents as inbound notifications that the OS or a device change interrupted capture. - Stopping only the sender’s track and forgetting the local preview. The preview element usually holds a second reference from the original
MediaStream; if you cloned the track, stopping one clone leaves the device open and the LED on. Fix: stop every clone, and confirm withstream.getTracks().every(t => t.readyState === 'ended'). - Muting audio by stopping the microphone track. Echo cancellation loses its reference signal and needs to re-converge on unmute, which produces a second or two of audible echo — see Audio Focus & Echo Cancellation Across Devices.
FAQ
Which one should a normal mute button use?
track.enabled = false for both audio and video, plus an explicit mute flag on the signaling channel so remote UIs can render a mute badge. It is sub-millisecond, reversible, leaves the SSRC and SDP untouched, and never risks a permission prompt. Add a separate, clearly labelled “turn camera off” control that calls track.stop() when your product makes a hardware privacy promise.
Does track.stop() require renegotiation?
No. Stopping a track ends it and halts RTP, but the sender, the transceiver, and the m-line all survive, so nothing in the SDP changes and negotiationneeded does not fire. Only changing transceiver.direction — or calling transceiver.stop(), which zeroes the m-line’s port — is an SDP-level operation.
Why does the remote still show a live-looking tile when I mute?
Because enabled = false keeps real RTP flowing, so the remote receiver never enters the muted state; it just decodes black or silence. If you want the far end to detect the pause from the media itself you must actually stop the RTP, and even then the remote only learns that packets stopped, not that the user chose to mute. Reacquiring devices after a stop, including the permission edge cases, is covered in Handling Device Hotplug & Permission Changes.
Related: return to Audio/Video Track Management, and read Replacing Video Tracks Without Renegotiation for the resume path alongside Reacting to Bandwidth Drops with RTCRtpSender Parameters.