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
- Use Firefox as the client browser.
- Connect to a room served by the HPB (Standalone Signaling Server + Janus) with simulcast enabled.
- Throttle the client's uplink to < 1 Mbps (e.g., using Firefox DevTools Network tab or a low-bandwidth network).
- Join a call with video enabled.
- 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
checking → connected → disconnected → failed.
- 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.js — Peer.prototype.offer()
spreed/src/utils/webrtc/simplewebrtc/simplewebrtc.js — maxBitrates configuration
spreed/src/utils/signaling.js — joinResponseReceived()
nextcloud-spreed-signaling/server/hub.go — processRoom() (sends Bandwidth in room response)
nextcloud-spreed-signaling/api/signaling.go — RoomBandwidth.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
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 returnedPromiseis not awaited beforecreateOffer()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 configuredmaxstreambitrate, the publisher saturates its uplink. This causes severe packet loss, ICE connection failures, and a constant disconnect/reconnect loop for subscribers.Steps to Reproduce
Expected Behavior
rid: h/m/l) should be fully applied before the offer is created.maxstreambitratelimit instead of using hardcoded values.Actual Behavior
checking→connected→disconnected→failed.requestOfferloop inwebrtc.js).about:webrtcshows packet loss spiking before each disconnect.Root Cause Analysis
1.
setParameters()race condition inpeer.jsIn
src/utils/webrtc/simplewebrtc/peer.js, theoffer()method:sender.setParameters()returns aPromise. 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
maxBitratesignoring server bandwidth limitIn
src/utils/webrtc/simplewebrtc/simplewebrtc.js:The Standalone Signaling Server already sends
room.bandwidth.maxstreambitratein the room join response (seeserver/hub.goandapi/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 offerIn
src/utils/webrtc/simplewebrtc/peer.js, restructure theoffer()method so thatcreateOffer()is only called aftersender.setParameters()resolves:Fix 2: Calculate
maxBitratesdynamically from the signaling server's limitIn
src/utils/webrtc/simplewebrtc/simplewebrtc.js, replace the hardcodedmaxBitrateswith a function that derives values fromopts.connection.maxStreamBits:And in
src/utils/signaling.js, store the value from the room join response:Environment
mainbranch (post-31)nextcloud-spreed-signalingwith Janus MCU, simulcast enabledRelated Code
spreed/src/utils/webrtc/simplewebrtc/peer.js—Peer.prototype.offer()spreed/src/utils/webrtc/simplewebrtc/simplewebrtc.js—maxBitratesconfigurationspreed/src/utils/signaling.js—joinResponseReceived()nextcloud-spreed-signaling/server/hub.go—processRoom()(sendsBandwidthin room response)nextcloud-spreed-signaling/api/signaling.go—RoomBandwidth.MaxStreamBitrateSeverity
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