Networking & media Real-time voice, from the packet up

Where the server sits

Mesh, SFU, MCU and the server that is the other end.

On this page · 8 sections

So far every call we’ve followed had two ends. At 9:17 AM in Paris, Alice talks to her assistant: her voice leaves as one packet every 20 ms (post 1), over the path ICE found (post 3), locked with keys only she and the assistant’s server hold (post 4), and the server reports back on how it’s arriving (post 5).

Later that morning she’s on a call with Bob and Carol, and she opens with “So the plan is to ship on Friday.” Now that sentence has two listeners. (Bob and Carol talk too, and their voices take exactly the same routes as hers, so we’ll follow Alice’s.) Every packet of her sentence has to reach Bob and Carol, so somebody has to make a second copy, and each copy has to be locked with a key its listener can open. Who makes that copy is the whole question. We’ll follow her sentence through the three answers, count what each one costs, and then go back to her assistant, where the server turns out to be none of the three.

The short version

  • Alice can send a copy to each listener herself: her upload goes from 48 kb/s to 96 with three people, and to 432 with ten. No server needed.
  • A server can make the copies: Alice sends once, and the server unlocks her packet and relocks a copy for each listener, without ever turning it back into sound. Its traffic grows fastest: 6 streams out with three people, 90 with ten.
  • A server can mix everyone into one stream per person: the laptops send one and receive one, and the server turns every voice back into sound, mixes, and compresses the result again for each person, every 20 ms. Measured, that’s about a hundred times the work of copying.
  • Any machine that unlocks the audio can hear it, so in the last two the server can hear the call.
  • A voice assistant’s server is none of these. It’s simply the other end of the call: one stream each way.

Two listeners, two locked copies#

Think of it as mail. Alice has a letter for Bob and Carol, and each letter goes in an envelope only its addressee can open. There are three ways to get it to them:

AliceHow the letter gets thereIn a call
She writes it twiceTwo envelopes, two stamps, both posted by herMesh
She sends one to a post officeThe post office opens her envelope, copies the letter, and seals each copy in a fresh envelope for each friendSFU, a selective forwarding unit
She sends it to an editorThe editor reads every letter from everyone, and writes each person one letter combining what the others saidMCU, a multipoint control unit, or mixer

The envelopes are the part that’s easy to miss, and they come from post 4. Keys are made in a handshake between two machines, so every pair of machines that talk directly, every leg of the call, has its own. A packet locked for one leg can’t be opened on another. That’s why making a copy is never just copying.

The packet we’re following is post 1’s: 20 ms of Alice’s voice, 80 bytes of sound and 40 of headers, 120 bytes, sent 50 times a second, which is 48 kb/s. (Post 4’s 10-byte tag makes it 130 bytes, about 52 kb/s; the counts here stay with post 1’s 120 bytes, so every kb/s figure runs about 8% low.) Here’s what happens to it in each shape, with three people on the call:

Four panels. Mesh: Alice, Bob and Carol linked to each other, two arrows out of each laptop, no server; each laptop sends 96 kb/s and receives 96 kb/s. SFU: each person sends one stream up to the SFU and gets two back; each laptop 48 kb/s up and 96 down, the server 144 kb/s in and 288 out. MCU: each person sends one stream to a mixer and gets one mix back; each laptop 48 up and 48 down, the server 144 in and 144 out. One to one: Alice sends one stream to the service and gets one reply; 48 kb/s each way.
Three people, three ways to make the second copy of every voice, and the one-to-one call where nobody needs one.

I wrote a small script, topology_cost.py, that does this counting for any size of call, and every traffic number from here on comes from it. The counting itself is a handful of lines, one function per shape:

python
topology_cost.py
def mesh(n: int) -> Cost:
peers = n - 1
return Cost("mesh", peers, peers, None, None, ...)
def sfu(n: int, loudest: int | None = None) -> Cost:
k = n if loudest is None else min(loudest, n)
heard = min(k, n - 1) # a listener who is not among the k hears all k; a selected speaker hears k - 1
out = k * (n - 1) # each forwarded stream goes to everyone but its sender
name = "SFU" if loudest is None or k == n else f"SFU, {k} loudest"
return Cost(name, 1, heard, n, out, ...)
def mcu(n: int) -> Cost:
return Cost("MCU", 1, 1, n, n, ...)

Trimmed to the stream counts; the ... is the per-box work, which comes later.

The four numbers in each Cost are streams: what one laptop sends, what it receives, what the server receives, what the server sends. n is the number of people. In a mesh each laptop sends and receives n - 1, one per other person, and there’s no server. The SFU receives n and sends out, each voice to everyone but its owner. k is how many voices it forwards: all of them for now (we’ll get to forwarding only the loudest), so each listener hears heard = n - 1. The MCU receives n and sends n, one mix each. Multiply by 48 kb/s and you have the traffic. For Alice’s call, n = 3:

text
python3 topology_cost.py -n 3
one client the server
pattern up kb/s down kb/s in kb/s out kb/s out pkt/s
mesh 96 96 - - -
SFU 48 96 144 288 300
MCU 48 48 144 144 150

The last column counts packets rather than bits, because a server spends its effort per packet: every one has to be received, unlocked or locked, and sent. To keep the counting simple, the script assumes everyone sends all the time, and leaves out the small reports from post 5.

With three people the shapes barely differ. They start to differ when the call grows, so let’s take them one at a time and see what happens to Alice’s sentence in each.

Mesh: Alice posts every copy herself#

In a mesh, Alice’s browser opens a separate connection to Bob and another to Carol, each with its own path, its own handshake and its own keys. Every 20 ms she locks that slice of “ship on Friday” twice, once for each of them, and sends it twice. That’s her 96 kb/s up.

Now imagine the call grows to ten people. Her sentence goes out nine times: 432 kb/s, nine times a two-person call, and the same from every laptop on the call, over whatever connection each person happens to have (RFC 8834 §12.1.2 describes the same cost).

Does Alice encode her voice once, or once per listener?

Possibly once per listener. Each connection backs off on its own, the way post 5 described, so Bob’s might be running at 32 kb/s on the wire while Carol’s is still at 48, and one encoder can’t produce both. RFC 7667 draws the WebRTC kind of mesh with the microphone “fed into two different encoder instances” for that reason (§3.4). Whether a particular browser shares one encoder between connections I haven’t checked, so take the encodes as “up to one per listener”. The locking isn’t in doubt: one connection per listener means one key per listener.

Your own browser can show you this. Start the call, then add listeners one at a time, and watch Alice’s upload:

A mesh call in this tablive lab

This lab runs a real call in your browser, so it needs JavaScript.

What mesh buys is privacy. There’s no machine in the middle, so nobody but the people on the call ever holds a key.

The cost is that every copy is made and sent by the laptops, and every person who joins adds one more to each of them. What if a server made the copies instead?

SFU: the post office#

With an SFU, Alice sends her sentence once, to the server, and the server sends a copy to Bob and another to Carol. Her upload stays at 48 kb/s however big the call gets.

The listeners don’t get off as lightly. Bob still receives a separate stream for each other person, and plays them together on his own laptop. And the server’s way out is where the growth goes: each person’s voice leaves it once per listener. With three people that’s 3 × 2 = 6 streams, 288 kb/s. With ten:

text
python3 topology_cost.py -n 10
one client the server
pattern up kb/s down kb/s in kb/s out kb/s out pkt/s
mesh 432 432 - - -
SFU 48 432 480 4320 4500
MCU 48 48 480 480 500

Ten voices, nine copies each: 90 streams, 4,500 packets every second out of one server, for one call. Twenty people would be 380. That’s growth with the square of the call: the number of people times the number of listeners each one has.

Two line charts over 2, 3, 5 and 10 people, to scale. Left, one laptop's upload: mesh 48, 96, 192, 432 kb/s; SFU and MCU flat at 48. Right, what the server sends: SFU 96, 288, 960, 4,320 kb/s; MCU 96, 144, 240, 480.
Mesh grows each laptop’s upload. The SFU keeps that flat and grows its own traffic instead.

A mesh sends the same 90 copies; it just spreads them across everyone’s laptops. An SFU sends all 90 itself.

Why the post office has to open the envelope#

To forward Alice’s packet, the SFU needs to know whose voice it is, and that’s written on the outside: the RTP header, which post 4 showed is never encrypted. So you might expect it to read the label and pass the sealed envelope along untouched.

It can’t, because Alice sealed it for the SFU, not for Bob. She did her handshake with the server, and so did Bob, separately. Alice and Bob never did one with each other, so Bob holds no key that opens her packet. The SFU has to unlock each packet with Alice’s key and lock a fresh copy with Bob’s, and another with Carol’s. What it never has to do is decode the sound, turn the bytes back into audio. RFC 7667, which calls an SFU a “selective forwarding middlebox” (SFM), puts the rule plainly: “If encryption is employed, the Media Translator, SFM, and mixer need to be able to decrypt the media to perform its function” (§6).

A 130 B packet from Alice, drawn to scale: IPv4 20 B, UDP 8 B, RTP 12 B readable without decrypting, 80 B of Opus encrypted under Alice's leg key, a 10 B tag. The SFU opens it once (verifies the tag, decrypts: 1 decrypt), copies it with a rewritten sequence number per leg, and encrypts each copy under the receiver's key with a new tag (2 encrypts). Two 130 B packets leave, one for Bob and one for Carol. The Opus is never decoded.
The label is free to read. The envelope has to be opened with the sender’s key and resealed with each receiver’s. (The rewritten number matters later, when the SFU starts skipping packets.)

Now be the SFU yourself. Press Forward it untouched to Bob first, then Unlock with Alice’s key, relock for Bob and Carol. The keys are made in your browser when the page loads, so the first one really does fail:

Be the SFUlive lab

This lab runs a real call in your browser, so it needs JavaScript.

The second script, measure.py, runs a real SFU on one laptop, with real SRTP (post 4’s locking) on real packets, so we can time it. Its whole job, every 20 ms, is this loop:

python
measure.py
for i in range(n):
plain = s_in[i].unprotect(wire[i][f])
for j in range(n):
if j != i:
s_out[j].protect(plain)

SRTP’s words for unlocking and locking are unprotect and protect. For each person i, the SFU unlocks their packet once, with the key it shares with them. Then, for every other person j, it locks a copy with the key it shares with j. There’s no decoder in the loop and no encoder. For Alice’s call that’s 3 unlocks and 6 locks every 20 ms, and nothing else.

Forwarding only the people talking#

On a big call, most people are listening most of the time, so forwarding all ten voices to everyone is mostly forwarding silence. An SFU could forward only the loudest few, but to know who’s loud it would seem to have to decode everyone, which is exactly the work it exists to avoid.

The fix is to have each sender write down its own loudness. You saw it in Alice’s offer in post 4, as ssrc-audio-level: each packet carries one extra byte saying how loud that 20 ms was, from 0 for the loudest to 127 for silence (RFC 6464). The SFU compares those numbers across senders and forwards the top few, without listening to anything. The open-source SFU mediasoup describes its version in seven words: “No decoding of audio data is done” (docs).

The audio-level extension after the 12 B RTP header: a 4-byte marker (BE DE 00 01), a byte naming the extension, the loudness byte itself (0, loudest, to 127, silent, plus a voice flag), and two bytes of padding, 8 B in all. Below, ten senders into an SFU that forwards only the three loudest. At 10 people: the SFU sends 4,320 kb/s against 1,296; one listener receives 432 kb/s against at most 144; 90 locks every 20 ms against 27; 0 decodes either way. Bars drawn to scale.
Each sender says how loud it is, so the SFU can pick the three loudest of ten without decoding a frame.
Ten peopleForward everyoneForward the 3 loudest
The SFU sends4,320 kb/s1,296 kb/s
One listener receives432 kb/s144 kb/s, or 96 for one of the three speakers
Locks every 20 ms9027
Sounds decoded by the server00

From topology_cost.py -n 10 --loudest 3, still counting post 1’s 120-byte packet.

The byte isn’t free: in its common short form, the extension adds 8 bytes to every packet, so Alice’s packet with it and post 4’s tag is 138 bytes rather than 130.

Why skipping packets means renumbering them

Each stream Bob receives is numbered packet by packet, and post 5’s loss count reads a gap in the numbers as a lost packet. If the SFU stops forwarding Carol while she’s quiet, Bob’s laptop would count her silence as loss. So an SFU that skips packets renumbers each stream it sends, which is the rewritten number in the figure above. Post 4 showed that the header is readable but covered by the tag, so it can’t be changed without the key. That’s no obstacle here: the SFU is writing a new tag for every copy anyway, with the listener’s key, and the new number goes under it.

Can the loudness byte be hidden?

It rides in the clear, like the rest of the header, and a loudness trace can give away when someone is speaking. RFC 8834 recommends encrypting it, and RFC 9335’s “cryptex” does that, though post 4 found libwebrtc ships it switched off. Either way it isn’t hidden from the SFU, which holds the keys.

Video does the same, with sizes instead of speakers

For video, an SFU picks which version of each stream to send each receiver: senders upload a few sizes at once (simulcast, RFC 8853) or one stream built in layers, and the SFU forwards the size that fits each receiver’s connection. Still no decoding: it’s choosing packets by their headers, as with audio.

Even with the SFU doing all the copying, Bob’s laptop still receives a separate stream for each speaker, decodes each one and plays them together. What if the server did that too?

MCU: the editor#

An MCU is the editor from the mail analogy. It decodes everyone’s stream back into sound, adds the sound together, and sends each person a single stream.

But not the same stream to everyone. Bob’s mix has to leave Bob out: his own voice coming back from the server, a moment after he said it, would sound like an echo. RFC 7667 says exactly that: “each endpoint gets a mix of all other sources except its own, as this would result in an echo” (§3.6.1). A mix of everyone but the listener is called mix-minus. So Alice’s “ship on Friday” goes into Bob’s mix and Carol’s, but not into her own.

Hear it for yourself. Choose to be Bob and play your mix, then tick Leave my own voice in the mix and play it again:

Hear mix-minuslive lab

This lab runs a real call in your browser, so it needs JavaScript.

So with three people the MCU makes three different mixes, and encodes and locks each one for its listener.

Alice's, Bob's and Carol's streams are each unlocked and decoded, summed, and then three outputs leave: the sum minus Alice, which is Bob plus Carol, goes to Alice; minus Bob to Bob; minus Carol to Carol. Each is encoded and locked with its listener's key. The measured cost of each step, in µs (millionths of a second): unlocking about 1, decoding about 45, encoding about 340, locking about 1.5. A note: every re-encode adds 6.5 ms of delay, the encoder's look-ahead, and re-encoding a lossy codec costs quality.
One mix per listener, each leaving out the listener’s own voice. Encoding is by far the most expensive step. (Each step was timed on its own, one call at a time, which comes out higher per lock than the SFU’s tight loop below; compare the loop totals, not these.)

For the laptops this is the cheapest shape there is: one stream up, one down, one decode, whatever the size of the call. That’s the reason mixers exist at all: RFC 7667 notes they “were introduced in pre-RTP times” so that devices able to take only a single stream could join a conference (§3.6.1). The server’s traffic is modest too: one stream in and one out per person, 144 kb/s each way for Alice’s call.

The price is paid in the server’s processor. Here’s measure.py’s MCU, one 20 ms frame:

python
measure.py
for i in range(n):
payload = m_in[i].unprotect(wire[i][f])[12:]
pcm.append(np.concatenate([fr.to_ndarray().reshape(-1) for fr in decs[i].decode(av.Packet(payload))]))
stacked = np.stack(pcm).astype(np.int32)
total = stacked.sum(axis=0)
for i in range(n):
mix = np.clip(total - stacked[i], -32768, 32767).astype(np.int16)
packets = encode_frame(encs[i], mix, f * SAMPLES)
for p in packets:
m_out[i].protect(rtp(f, f * SAMPLES, 0x4D430000 + i, p))

The timing lines are left out.

Read it in three steps:

  1. Unlock and decode everyone. For each person, unlock the packet, drop its 12-byte header, and decode the 80 bytes of Opus back into sound: 960 numbers, one for each 1/48,000 of a second.
  2. Add everyone once. total is the whole room, every voice added together, number by number.
  3. Take each listener back out. total - stacked[i] is the mix-minus: the room minus person i. The sum is done in 32-bit numbers so it can’t overflow, and np.clip then squeezes it back into what a 16-bit sample can hold, because three loud voices added together can exceed it. Then the mix is encoded into Opus again (encode_frame returns a list, usually of one packet), wrapped in a fresh RTP header by rtp(...), with the MCU’s own stream ID because each mix is a new stream, and locked for person i alone.

The long second line looks worse than it is: it decodes the packet and flattens the result into one row of 960 numbers.

Where the SFU’s loop had no decoder and no encoder, this one has one of each per person, every 20 ms. The script times both on the same laptop, a thousand frames each, and the gap is about a hundred times at every size. For Alice’s call, the SFU took 7 µs a frame and the MCU about 1.2 ms. At ten people, mixing one 20 ms frame took half of those 20 ms, so one processor core could keep up with about two such calls; forwarding the same call took 77 µs, room for about 260.

Grouped bars on a log axis from 1 to 100,000 µs, with a dashed line at 20,000 µs, the length of one frame. At 3 people the SFU takes 7 µs per frame and the MCU 1,187 µs (162x); at 5, 20 and 1,977 µs (98x); at 10, 77 and 10,431 µs (135x). At 10 people the MCU uses 52% of one core's 20 ms. Medians of 1000 frames, one Apple M3 Pro, from Python, one core.
Measured on one laptop with synthetic voices: the MCU took about a hundred times the SFU’s time at every size.
PeopleMCU, time per 20 ms frameSFUMCU / SFU
31,187 µs7 µs162x
51,977 µs20 µs98x
1010,431 µs77 µs135x

Median of 1,000 frames on one Apple M3 Pro, one core, from Python. A server written in C or Go would be faster at both, so don’t plan capacity from these numbers.

The gap is the claim, and my guess is that it’s an underestimate: the MCU’s time is mostly libopus, which is already C, while the SFU’s tiny times are mostly Python calling into libsrtp, which a C server wouldn’t pay.

The measurement in detail, and why ten people cost five times as much as five

The voices are post 2’s synthetic vowel, one per person at a different pitch. The libraries are PyAV 19.0.0 (libopus 1.6.1) for Opus and pylibsrtp 1.0.0 for SRTP, with every codec and key kept alive for the whole call, as a server would. The ratios in the table come from the unrounded medians, which is why 1,187 / 7 doesn’t give exactly 162. They jump around, 162x then 98x then 135x, because the SFU’s times are so small, 7 to 77 µs, that a microsecond either way moves the ratio a lot.

Ten people cost more than twice five because Opus picks one of three modes frame by frame, depending on the sound: SILK, built for speech, CELT, built for music and dense sound, and a hybrid of the two. Encoding a frame in SILK cost 1,242 µs here against 337 µs in CELT, and libopus put 81% of the ten-person mixes into SILK alone. So an MCU’s cost depends on what it’s mixing, not only on how many people there are. Real speech may land differently.

Two costs don’t go away with a faster processor. Opus holds back a little sound each time it encodes so it can look ahead, 6.5 ms in this build, so the MCU’s second encode adds that much delay to everyone’s audio. And Opus throws away detail to compress, so decoding and compressing again loses a little more, like a photocopy of a photocopy. (I haven’t measured that loss here.)

A real implementation of each, to read

The open-source Janus server ships both shapes: its VideoRoom plugin is an SFU, and AudioBridge is “an audio conference bridge … specifically mixing Opus streams” (docs).

Now that all three are on the table, grow the call. Pick a shape, drag the number of people up, and watch where the lines pile up. Then set the SFU to forward only the 3 loudest:

Watch the streams multiplylive lab

This lab runs a real call in your browser, so it needs JavaScript.

Who can hear the call#

Back to the mail for a moment. The post office never reads Alice’s letter, but it does open the envelope, and whoever opens the envelope could read it. The same goes for every machine we’ve met that unlocks the audio: each laptop in a mesh, the SFU, the MCU. Each one did the connectivity checks from post 3 and a handshake from post 4, holds keys, and unlocks the sound. Being that machine is called terminating the call. A server in the middle still counts: each leg has two ends, and the SFU or MCU is one end of every leg it serves.

Here’s a packet you’ve seen before, Alice’s to her assistant in post 4, locked. Any leg looks the same, so here’s what three different kinds of machine on its path can read:

IPv4 headerbytes 0 to 1945 b8 00 82 … c0 00 02 0a c6 33 64 14A router reads this, to pick the next hop: from 192.0.2.10 to 198.51.100.20, the assistant's server, 130 bytes long. It holds no keys.
UDP headerbytes 20 to 27d4 31 c3 54 00 6e …A keyless relay reads this far, the addresses and ports, to pick where to send it on. You've met one: post 3's TURN relay carries every packet of a call, locked as they are.
RTP headerbytes 28 to 3980 6f 03 f2 00 00 25 80 1f 2e 3d 4cAnyone can read this, never encrypted: packet 1010 of Alice's stream 0x1F2E3D4C. The label on the envelope.
Opus, encryptedbytes 40 to 11962 23 76 62 5a f9 71 28 … 72 moreOnly an endpoint reads this: the machine holding this leg's key. It's the one that terminates the call.
SRTP tagbytes 120 to 129Post 4's 10-byte tag, which proves nothing above it was changed. 130 bytes on the wire
Alice's 130 B packet with byte offsets: IPv4 0 to 19, UDP 20 to 27, RTP 28 to 39, encrypted Opus 40 to 119, tag 120 to 129. Three boxes below it: a router, a keyless relay and an endpoint. A router reads bytes 0 to 19, looks up 198.51.100.20 and picks the next hop; it forwards. A keyless relay reads only enough to pick a destination, holds no keys and does not decrypt; it forwards. An endpoint (a mesh peer, an SFU, an MCU or a service) has completed ICE and DTLS, holds the SRTP keys, verifies the tag, decrypts, and decodes if it needs the sound; it terminates, and so can hear the call.
Only the box that holds the keys terminates the call, and whatever terminates can hear it.

So the rule is simple: whatever terminates the call can hear it. In a mesh that’s only the people on it. With an SFU or an MCU, it’s the server too, and RFC 7667 warns that one that’s “subverted or misbehaving” has “full access” (§6).

An SFU that can't listen

There’s a way to keep an SFU out of the audio: put a second lock on the sound, inside the first, with a key only the people on the call share. The SFU still unlocks the outer layer, reads the header and forwards, but the sound inside stays locked to it. That’s what SFrame (RFC 9605) and the double encryption of PERC, Privacy-Enhanced RTP Conferencing (RFC 8723) do. An MCU can’t work that way at all, because it has to hear the sound to mix it.

Back to the assistant: the server is the other end#

Back to 9:17 AM, Alice and her assistant. In the mail analogy this is a letter to one pen pal: one envelope, no copies, no post office, no editor. The assistant’s server isn’t forwarding Alice to anyone or mixing her with anyone; it’s simply the other end of the call. RFC 7667’s catalogue of shapes starts with exactly this one, point to point: “A sends to B, and only B, while B sends to A, and only A” (§3.1). The server is B.

The work per conversation is one stream each way, 48 kb/s. Every 20 ms there’s one unlock and one decode of what Alice says, since the AI model needs her voice as sound, and one encode and one lock of the reply. That’s one seat at an MCU, without the mixing. measure.py times it too: 384 µs a frame on the same laptop, about fifty conversations per core at that speed.

Large voice services have written about this choice. OpenAI’s article on their voice infrastructure (OpenAI) says they considered the usual group-call design, an SFU, with the AI joining the call as one more participant. But “[m]ost sessions are 1:1”, one person talking to one model, so there’s nothing to forward.

What they built instead is a service at the edge, meaning the first of their servers a user’s packets reach, which terminates the call. They call it a transceiver. Behind it, the audio travels to the other services in “simpler internal protocols”, so that those services can “scale like ordinary services instead of acting as WebRTC peers themselves”.

Why does that matter? Because being a WebRTC peer means doing everything we’ve covered so far:

A WebRTC peer has toFrom
Keep a jitter buffer and cover for lost packetsPost 2
Answer connectivity checks and keep the path alivePost 3
Do a handshake and hold keys for every callPost 4
Send and read reports, and back off when the path gets worsePost 5

And all of that belongs to one machine for the whole call. The path from post 3 ends at that machine’s address, and the keys from post 4 live in its memory. If it’s busy, the call can’t simply move to another one. An ordinary service, the kind that’s easy to scale, works the other way: any of many identical machines can take the next piece of work, so you add machines when it’s busy and take them away when it isn’t. With a terminating edge, only the edge has to be a WebRTC peer, and the AI model behind it just receives audio, like an ordinary service.

Choosing one#

Here’s Alice’s one 20 ms slice once more, through every shape at once:

One 20 ms frame of Alice's voice, followed through each box. Mesh: Alice encodes and encrypts twice, once under Bob's key and once under Carol's, and sends 2 x 120 B; Bob and Carol each decrypt and decode hers. SFU: Alice encodes and encrypts once; the server decrypts with Alice's key and encrypts a copy under Bob's key and one under Carol's, 1 decrypt and 2 encrypts; each listener decrypts, decodes and mixes. MCU: the server decrypts and decodes hers, adds her into Bob's and Carol's mixes, then encodes and encrypts each mix. One to one: the service decrypts and decodes (at the edge or behind it), then encodes and encrypts the reply, which Alice decrypts and decodes: 1 encode per frame, not 0.
Who does what to one 20 ms frame, in each shape. In the one-to-one call the reply is encoded too.
ShapeWhat grows with the callWho paysWho can hear the callWhat you get
MeshEach laptop’s upload and download, one stream per other person, and its decodesEvery laptop’s connection and processorOnly the people on itNo server at all
SFUThe server’s traffic, people × listeners, and each listener’s downloadServer bandwidth, and a lock per copyThe SFUEvery speaker as a separate stream, no sound processing on the server, speaker selection to tame the growth
MCUThe server’s work, a decode and an encode per personThe server’s processor, plus a little delay and qualityThe MCUOne stream per laptop, for devices that can’t take more
The server is the other endNothing: one stream each way, per conversationThe service’s codec work per userThe serviceA backend that doesn’t have to be a WebRTC peer

Read off the table: two or three people who care about privacy, mesh. A group call, an SFU, forwarding only the loudest few once it’s more than a handful. Devices that can only take one stream, or a bridge to the phone network, where each call is a single channel of sound, a mixer. One person and a service, terminate at the edge and let everything behind it be an ordinary service.

To compare exact numbers for a call of any size, all four shapes side by side. Its Packet choice adds what a real call carries on top of post 1’s 120 bytes: the SRTP tag, IPv6’s longer header, and header extensions such as the audio level:

Call topology cost/tools/call-topology-cost/

The demo#

Both scripts are in where-the-server-sits/: topology_cost.py does the counting for any size of call, and measure.py runs a real MCU, a real SFU and the one-to-one case with real Opus and real SRTP, and times a frame of each.

Shell
Terminal window
cd mainlog-code/where-the-server-sits
python3 topology_cost.py # no install needed: 2, 3, 5 and 10 people
python3 topology_cost.py -n 10 --loudest 3 # an SFU forwarding the three loudest
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python measure.py # the timings, on your machine

Its CI checks the counting against a slow count that lists every copy of every voice, for every call size from 2 to 30, and checks that the MCU stays more than twenty times the SFU’s cost on whatever machine runs it.

So Alice’s “ship on Friday” can be copied by her own laptop, by a server that unlocks and relocks it, or by one that mixes it into everyone else’s sound, and in each case the cost lands somewhere different. Her assistant needs none of that, but its server terminates the call and has to own it, path, keys and all, for as long as the call lasts. That makes it hard to put behind an ordinary load balancer, which spreads packets across machines without knowing which call each belongs to. Next, how a packet finds the one server that owns its call, without the box in front holding any keys.

Share this article

Related posts

3 items
  • Oct 23, 2026 21 min read AI

    SFU, MCU, and the participant that has to decode

    Put an AI agent in a group call and two questions decide the design: does the model hear each person, or one blend, and who has to hold the keys? Both come down to where the agent's audio gets decoded.

    AI & agentic architecturesai-agentswebrtcarchitecture
  • Aug 21, 2026 8 min read Networking & media

    Half the bitrate, two thirds the traffic

    Halving an audio bitrate from 32 to 16 kb/s took traffic from 48 to 32, because every packet's headers stay the same size. Packet rate is the other control.

    Down to the wireaudionetworkingperformance
  • Oct 30, 2026 24 min read Software craft

    Inside toolbench: the runtime for small interactive web tools

    The internals of toolbench, the framework behind mainLog's tools: what the runner guards against, why a worker exists only to be terminated, how a graph is laid out and routed the same in every browser, how known answers and size budgets keep tools honest, and why every migration is the identity function.

    frameworksarchitecturetesting
  • AI

    SFU, MCU, and the participant that has to decode

    Put an AI agent in a group call and two questions decide the design: does the model hear each person, or one blend, and who has to hold the keys? Both come down to where the agent's audio gets decoded.

    ai-agentswebrtc
    Oct 2026
  • Networking & media

    Half the bitrate, two thirds the traffic

    Halving an audio bitrate from 32 to 16 kb/s took traffic from 48 to 32, because every packet's headers stay the same size. Packet rate is the other control.

    audionetworking
    Aug 2026
  • Software craft

    Inside toolbench: the runtime for small interactive web tools

    The internals of toolbench, the framework behind mainLog's tools: what the runner guards against, why a worker exists only to be terminated, how a graph is laid out and routed the same in every browser, how known answers and size budgets keep tools honest, and why every migration is the identity function.

    frameworksarchitecture
    Oct 2026

Comments