Skip to content

Firefox simulcast: setParameters() race condition causes failed simulcast initialization on low-bandwidth connections #17774

@tareko

Description

@tareko

Description

When using Firefox with the High Performance Backend (HPB / MCU) and simulcast enabled, the publisher side initializes simulcast encodings via sender.setParameters(). However, the returned Promise is not awaited before createOffer() is called. This creates a race condition where the offer SDP may be generated before the simulcast parameters are actually applied to the sender. Please note that I used OpenCode with Kimi 2.6 and Qwen 3.6 35B3A to help me find, debug, and fix this issue.

On low-bandwidth connections, this is catastrophic: if simulcast fails to initialize, Firefox falls back to a single high-bitrate stream. Combined with hardcoded maxBitrates (high: 900000, medium: 300000, low: 100000) that ignore the signaling server's configured maxstreambitrate, the publisher saturates its uplink. This causes severe packet loss, ICE connection failures, and a constant disconnect/reconnect loop for subscribers.

Steps to Reproduce

  1. Use Firefox as the client browser.
  2. Connect to a room served by the HPB (Standalone Signaling Server + Janus) with simulcast enabled.
  3. Throttle the client's uplink to < 1 Mbps (e.g., using Firefox DevTools Network tab or a low-bandwidth network).
  4. Join a call with video enabled.
  5. Observe the console logs and connection state.

Expected Behavior

  • Simulcast encodings (rid: h/m/l) should be fully applied before the offer is created.
  • The publisher should respect the signaling server's maxstreambitrate limit instead of using hardcoded values.
  • The connection should remain stable; if bandwidth is insufficient, the MCU should gracefully switch to a lower simulcast layer.

Actual Behavior

  • The connection repeatedly disconnects and reconnects.
  • Firefox console shows ICE state cycling through checkingconnecteddisconnectedfailed.
  • The MCU requests new offers every 10 seconds (requestOffer loop in webrtc.js).
  • about:webrtc shows packet loss spiking before each disconnect.

Root Cause Analysis

1. setParameters() race condition in peer.js

In src/utils/webrtc/simplewebrtc/peer.js, the offer() method:

if (sendVideo && this.enableSimulcast && adapter.browserDetails.browser === 'firefox') {
    const sender = this.pc.getSenders().find(function(s) {
        return s.track && s.track.kind === 'video'
    })
    if (sender) {
        let parameters = sender.getParameters()
        parameters.encodings = [
            { rid: 'h', active: true, maxBitrate: this.maxBitrates.high },
            { rid: 'm', active: true, maxBitrate: this.maxBitrates.medium, scaleResolutionDownBy: 2 },
            { rid: 'l', active: true, maxBitrate: this.maxBitrates.low, scaleResolutionDownBy: 4 },
        ]
        sender.setParameters(parameters)  // <-- Promise returned but NOT awaited
    }
}
this.pc.createOffer(options).then(function(offer) {
    // ...
})

sender.setParameters() returns a Promise. Because it is not chained or awaited, createOffer() runs immediately in the next tick, potentially before the simulcast encodings are committed by Firefox.

2. Hardcoded maxBitrates ignoring server bandwidth limit

In src/utils/webrtc/simplewebrtc/simplewebrtc.js:

maxBitrates: {
    high: 900000,
    medium: 300000,
    low: 100000,
},

The Standalone Signaling Server already sends room.bandwidth.maxstreambitrate in the room join response (see server/hub.go and api/signaling.go), but the frontend ignores it. When the server is configured with a limit lower than ~1.3 Mbps (the sum of the hardcoded layers), the publisher still tries to push the full hardcoded bitrate, congesting the link.

Proposed Fix

Fix 1: Await setParameters() before creating the offer

In src/utils/webrtc/simplewebrtc/peer.js, restructure the offer() method so that createOffer() is only called after sender.setParameters() resolves:

Peer.prototype.offer = function(options) {
    const sendVideo = this.sendVideoIfAvailable && this.type !== 'screen'
    const self = this

    const createOffer = function() {
        self.pc.createOffer(options).then(function(offer) {
            // ... existing simulcast SDP munging for Chrome/Safari ...
            self.pc.setLocalDescription(offer).then(function() {
                // ...
            })
        })
    }

    if (sendVideo && this.enableSimulcast && adapter.browserDetails.browser === 'firefox') {
        const sender = this.pc.getSenders().find(function(s) {
            return s.track && s.track.kind === 'video'
        })
        if (sender) {
            let parameters = sender.getParameters()
            if (!parameters) {
                parameters = {}
            }
            parameters.encodings = [
                { rid: 'h', active: true, maxBitrate: this.maxBitrates.high },
                { rid: 'm', active: true, maxBitrate: this.maxBitrates.medium, scaleResolutionDownBy: 2 },
                { rid: 'l', active: true, maxBitrate: this.maxBitrates.low, scaleResolutionDownBy: 4 },
            ]
            sender.setParameters(parameters).then(createOffer).catch(function(error) {
                console.warn('Failed to set simulcast parameters, falling back to normal offer', error)
                createOffer()
            })
            return
        }
    }

    createOffer()
}

Fix 2: Calculate maxBitrates dynamically from the signaling server's limit

In src/utils/webrtc/simplewebrtc/simplewebrtc.js, replace the hardcoded maxBitrates with a function that derives values from opts.connection.maxStreamBits:

function splitBandwidthIntegersOmitRemainder(totalBps = 1048576) {
    if (typeof totalBps !== 'number' || totalBps < 0) {
        totalBps = 1048576
    }
    const partValue = Math.floor(totalBps / 21 / 100) * 100
    return {
        low: partValue,
        medium: partValue * 4,
        high: partValue * 16,
    }
}

// Then in the constructor:
maxBitrates: splitBandwidthIntegersOmitRemainder(opts.connection?.maxStreamBits),

And in src/utils/signaling.js, store the value from the room join response:

// In Signaling.Standalone.prototype.joinResponseReceived
if (data.room?.bandwidth?.maxstreambitrate) {
    const totalBps = data.room.bandwidth.maxstreambitrate
    if (typeof totalBps === 'number' && totalBps > 0) {
        this.maxStreamBits = totalBps
    }
}

Environment

  • Nextcloud Talk version: Latest main branch (post-31)
  • Browser: Firefox (any recent version, e.g., 135+)
  • Signaling server: nextcloud-spreed-signaling with Janus MCU, simulcast enabled
  • Network condition: Low bandwidth (< 1 Mbps uplink)

Related Code

  • spreed/src/utils/webrtc/simplewebrtc/peer.jsPeer.prototype.offer()
  • spreed/src/utils/webrtc/simplewebrtc/simplewebrtc.jsmaxBitrates configuration
  • spreed/src/utils/signaling.jsjoinResponseReceived()
  • nextcloud-spreed-signaling/server/hub.goprocessRoom() (sends Bandwidth in room response)
  • nextcloud-spreed-signaling/api/signaling.goRoomBandwidth.MaxStreamBitrate

Severity

High — causes completely unusable video calls in Firefox for users on constrained networks.

Talk app

Talk app version: from main branch on github

Custom Signaling server configured: yes, version 69e616b8ae1f3ffd96fb25a02c1935a429c4c519~docker

Custom TURN server configured: yes

Custom STUN server configured: yes

Browser

Microphone available: yes

Camera available: yes

Operating system: GNU/Linux Ubuntu 25.10

Browser name: Firefox

Browser version: 149

Browser log

Details ``` Insert your browser log here, this could for example include: a) The javascript console log b) The network log c) ... ```

Server configuration

Operating system: Ubuntu
Web server: Nginx

Database: MySQL

PHP version: 8.3

Nextcloud Version: 33.0.2

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions