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

Late is lost

Jitter buffers, concealment and forward error correction.

On this page · 7 sections

Back to 9:17 AM in Paris. Alice asked her assistant about the weather, and the answer is streaming back to her laptop as fifty packets a second, each carrying 20 ms of sound.

The last post ended on a trade. Sending voice over UDP lets the receiver decide what a missing packet costs, and it hands the receiver the job of dealing with every late and missing packet itself. This post is about that job. We’ll see why packets arrive unevenly, how the receiver waits for them without making the call feel slow, what it plays when one doesn’t come, and how a sender can pack a spare copy of each frame into the next packet.

The short version

  • A packet that arrives after its playout time is as good as lost. On a voice call, late and lost are the same thing.
  • A jitter buffer waits a little for late packets. Every millisecond it waits is added to every frame, so it trades delay for dropouts.
  • Concealment makes up a missing frame from the audio before it. Bursts make the made-up stretches long.
  • Opus FEC carries a lower-bitrate copy of the previous frame inside the next packet, for the frames it judges important. Against scattered losses it rebuilds most of them; inside a burst, only the last.
  • A copy only helps if its packet arrives in time, so reaching further back needs a deeper buffer.

Terms we’ll use, each explained where it first matters:

TermIn one line
JitterHow unevenly packets arrive: one takes 31 ms, the next 48
Playout timeThe moment a frame has to be played. Its deadline
Jitter bufferWhere the receiver holds packets until their playout time
PLCPacket-loss concealment: the decoder makes up a missing frame from the audio before it
FECForward error correction: sending enough extra data that the receiver can rebuild a loss without asking
REDRedundant audio data: earlier frames carried again, whole, in later packets
RTCPRTP’s companion control channel, which carries reports and requests back to the sender
SDPThe text the two ends exchange at call setup to agree on codecs and options
libwebrtcGoogle’s WebRTC implementation, the one inside Chrome and many native apps

Packets don’t arrive on a beat#

Alice’s assistant sends a packet every 20 ms, like a metronome. They don’t arrive like one. Every packet crosses the same links, but each one meets the queues along the way in a different state: a router’s buffer that’s empty for one packet holds a dozen others for the next. So one packet might take 31 ms and the one behind it 48.

That unevenness is jitter. RTP even measures it: every receiver keeps a running estimate of how much the gaps between arrivals differ from the gaps between send times (RFC 3550 §6.4.1), and reports it back to the sender.

The receiver can’t play packets as they land, or the sound would speed up and stall with the network. It plays each frame at a fixed time instead: when it was sent, plus the normal trip, plus a margin. The margin is the jitter buffer, and the fixed time is the frame’s playout time. A packet that turns up after its playout time is no use, and RFC 7005 says so plainly: packets delayed excessively “may be discarded if they miss their playout time.”

Here are ten packets of Alice’s answer three ways: as they were sent, as they arrived, and as two different receivers play them.

Ten 20 ms packets. Top: sent evenly, one every 20 ms. Middle: they arrive bunched and spaced, with trip times from 31 to 58 ms. Third row: a receiver that plays each packet the moment it lands cuts some short and leaves silences between others, and Alice hears a broken sentence. Bottom: a receiver that plays every packet at its send time plus 70 ms plays all ten back to back, smoothly, 40 ms later than the trip alone would take.
The trip times are made up to show the shape, not measured. What matters is the bottom row: every packet landed before its slot, so a 40 ms wait turned a stuttering sentence into a smooth one.

That bottom row is the whole idea. Every frame gets a slot, and a packet only has to land before its own slot. Zoom in on one frame:

One frame's slot, built from a 30 ms normal trip and a 40 ms jitter buffer, so it plays 70 ms after it was sent. Packet A lands at 55 ms, waits 15 ms in the buffer and plays on time. Packet B lands at 82 ms, 12 ms after its slot, so it is discarded and Alice hears 20 ms the decoder made up. An inset shows the same two packets as dots on the simulator's chart: A below the dashed 70 ms line, B above it.
Packet A’s and B’s arrival times are made up to show the two cases. Packet B made it across the network. It’s still lost, because it got there after the moment it was needed. The inset is how the simulator below draws the same two packets.

Here’s what that looks like for a whole call. This post’s simulator runs a minute of Alice’s stream, 3,000 frames, over a modelled network: a 30 ms fixed delay, plus a random extra delay on each packet that averages 10 ms (that average is what the tool calls jitter), plus 3% loss, all with a 40 ms buffer. It’s a model, seeded so it gives the same minute every time, and it’s the one simulator for this whole post: we’ll come back to its later charts as we go.

Further down is what Alice hears: the first five seconds, a row per second, with a mark on every 20 ms frame that went wrong. Each one was either rebuilt from a copy (we’ll get to copies) or made up by the decoder. The chart after it is the dots from the figure above for every packet: the trip each one took, against the dashed line at 70 ms. Every dot above the line is a frame Alice didn’t get. At the top, it says how much of the minute the decoder made up, the longest gap, and the delay every frame pays, and when that’s 1% or more, which buffer or protection would fix it on the same network.

Jitter and FEC simulator/tools/jitter-fec/

Over the whole minute, 1.7% of packets arrived after their slot. The network delivered every one of them. They were just late, which on a call is the same thing as lost.

The buffer, and what it costs#

So why not wait longer? Because the buffer’s wait isn’t spent on the late packets alone. It’s added to every frame. A 40 ms buffer makes the whole conversation 40 ms more delayed, including for the packets that arrived with time to spare.

That’s the trade a jitter buffer makes, and the simulator’s first chart puts the buffer on the x axis: the same minute, replayed at every buffer from 0 to 200 ms. On a log scale, because the interesting part is down at a few percent:

BufferPackets too late
0 ms96.8%
20 ms12.5%
40 ms1.7%
60 ms0.2%
70 ms0%

The first few milliseconds of buffer buy a lot; the last few buy almost nothing. With no buffer at all, the model makes nearly every packet late, because its extra delay is never exactly zero; a real path has some packets that do arrive right on the fixed delay, but the shape is the same. Where to stop depends on the rest of the call’s delay budget. The usual reference is ITU-T G.114, whose current edition says that below 150 ms one way, most applications “will experience essentially transparent interactivity”. The buffer is one slice of those 150 ms, and the network, the codec and the device all want theirs.

Two kinds of buffer:

BufferFixedAdaptive
How it waitsThe same margin for every packetA margin it moves with the jitter it measures
RFC 7005 says”packets leaving the de-jitter buffer have a constant delay”it “can adapt to the change in the network’s delay”
When it’s rightA path whose jitter doesn’t changeAlmost everything else

The browser’s buffer is adaptive. libwebrtc’s, called NetEQ, starts at a target of 80 ms (delay_manager.cc) and then moves the target with the delays it measures, aiming at the 95th percentile of recent packets (delay_manager.h). When it holds more audio than its target, it plays slightly faster to drain; when it holds less, it stretches the audio slightly to refill (webrtcHacks walks through it). The simulator’s buffer is fixed, so it shows the trade without the adapting.

Ten seconds of packet delays as dots, with the buffer's target as an orange line. While the path is calm the target sits near 20 ms. Between 3.5 and 5.5 seconds the network gets worse and the dots spread up to well over 100 ms; the target climbs quickly to about 100 ms, stretching the audio as it does. When the path calms down the target comes back down slowly over several seconds, squeezing the audio. A few dots stay above the line even at its highest: those packets are late even for the bigger buffer.
The shape is NetEQ’s, the numbers are made up: up quickly when packets start arriving later, down slowly once they stop. Which makes sense: guessing low costs dropouts, guessing high only costs delay. The lab below shows the stretching and squeezing as two of Chrome’s counters.

Try it in the simulator: drag the jitter buffer down to 20 ms and watch the dots above the line multiply, and the made-up frames in what Alice hears with them, then pick the Jittery path sample, where 30 ms of average jitter makes a 40 ms buffer miss over a quarter of its packets (27.3%).

When a packet is missing anyway#

However deep the buffer, some packets never come. The receiver sees the sequence number skip, knows exactly which 20 ms are missing, and has to play something in their place, because the frame’s slot comes round whether there’s a frame or not.

Silence is the crudest choice: nothing at all where 20 ms of voice should be. So decoders conceal: the Opus specification asks for it on any unreliable channel (RFC 6716 §4.4). In its speech mode the decoder extrapolates the voice’s spectral shape from the previous frame; in its music mode it repeats the last stretch of waveform at the voice’s pitch. Either way it’s a guess from what came before.

How good a guess? We can measure it. This post’s demo, fec_recovery.py, encodes a second of a synthetic vowel-like sound with libopus, drops one packet, and scores what plays in its place against what the decoder would have played had nothing been lost. The score is a signal-to-noise ratio over that 20 ms. It measures how close the stand-in is to the real frame, not how it sounds: higher is closer, 0 dB is exactly as far off as silence, and a negative score is further off than silence.

text
decoder libopus 1.6.1; 32 kb/s, FEC on, planning for 10% loss
50 frames of 20 ms; frame 25 is lost; packet 26 is 80 B, carries an FEC copy
silence 0.0 dB SNR over the lost 20 ms
concealment (PLC) 6.5 dB SNR over the lost 20 ms
FEC from the next packet 14.1 dB SNR over the lost 20 ms

Concealment is a real improvement on silence for one frame. It doesn’t stretch, though: each further frame is a guess built on a guess, drifting further from what was said. One concealed frame is a 20 ms patch. Six in a row is 120 ms of made-up sound. You can make both happen, and listen to them, in the lab further down.

That’s why the shape of the loss matters as much as the amount. Here are two simulated minutes, both set to 3% loss, with no protection at all, so every missing frame is concealed. In one the losses are independent; in the other they come in bursts that average three packets. They’re separate draws, so the bursty minute happened to lose fewer packets:

MeasureRandom lossBursts of three
Packets lost2.8%2.0%
Packets late1.7%1.8%
Frames concealed4.5%3.7%
Longest gap40 ms180 ms

Less concealment overall, and a 180 ms hole, nine frames of made-up sound in a row, where the random minute never concealed more than two. (Bursts average three packets; some run much longer.) ITU-T’s model for estimating call quality treats that as worse, not better. Its burst ratio is 1 “when packet loss is random” and above 1 “when packet loss is bursty”, and the higher it goes, the worse the predicted quality at the same loss rate (G.107).

Sending it twice#

If guessing is the problem, the fix is to stop guessing: send each frame’s information again, in a later packet, so the receiver can rebuild a loss instead of making it up. That’s forward error correction, and voice over WebRTC has two flavours of it.

Opus in-band FEC. The encoder re-encodes each frame it judges important at a lower bitrate and tucks that copy into the next packet. RFC 7587 §3.3 puts it as adding “redundant information about the previous packet (N-1) to the current output packet N”. When packet 25 goes missing, the receiver waits for packet 26 and decodes 25 from the copy inside it.

Here’s the difference between one loss and a burst, packet by packet:

Five packets, 24 to 28, each carrying its own frame and a small copy of the frame before it. With one loss, packet 25 is gone, and the small copy of 25 inside packet 26 rebuilds it, so Alice hears every frame. With a burst, packets 25, 26 and 27 are gone; the copies of 25 and 26 were inside the lost packets, so 25 and 26 are made up, and only 27 is rebuilt from the copy in packet 28.
With one loss the copy saves the frame. In a burst the copies go down with the packets that carried them.

RED. RFC 2198 wraps whole earlier frames, at full quality, alongside each new one, as separate blocks with their own timestamps. It can carry one earlier frame or several.

SchemeOpus FECRED, 1 copyRED, 2 copies
What’s in packet Nframe N, plus a low-bitrate copy of N-1frames N and N-1frames N, N-1 and N-2
Reaches back1 packet1 packet2 packets
Copy qualitylower bitratefullfull
Costpaid for inside the Opus bitrateabout twice the audio bitrateabout three times

The RED costs follow from carrying whole copies: webrtcHacks’ write-up of Chrome’s audio RED puts a 30 kb/s stream at 60 with one copy and 90 with two, before headers.

The demo above already showed what the FEC copy is worth: 14.1 dB for frame 25 against concealment’s 6.5. One frame could be luck, so the demo also loses every frame in turn, from frame 5 to frame 48 (it counts from 0, skips the first few while the encoder settles, and stops before the last, which has no next packet to carry its copy):

text
28 of 44 packets carry an FEC copy of the frame before them
concealment (PLC) 3.9 dB on average over 44 positions, median 4.7, worst -1.9
5.4 dB where the next packet carries a copy (28 positions)
FEC from the next packet 13.5 dB on average over 44 positions, median 17.7, worst -1.9
20.4 dB where the next packet carries a copy (28 positions)

Look at the first line. Only 28 of the 44 packets carried a copy at all. Opus decides per frame whether a copy is worth the bits, and in this signal it skipped the quiet troughs between the “syllables”. Where there’s no copy, the decoder falls back to concealment, which is why both methods share the same worst case, -1.9 dB: a frame where the made-up sound landed further from the real one than silence would have. Where there is one, it’s 20.4 dB against 5.4: a rebuilt frame, not a guess.

The decoding is one call. Here it is from the demo, with the part that matters being the last argument:

python
def decode(dec: ctypes.c_void_p, packet: bytes | None, fec: bool = False) -> list[int]:
out = pcm_t()
# The last argument asks for the FEC copy inside `packet` rather than `packet`'s own frame. No packet at all
# (None) asks the decoder to conceal.
n = opus.opus_decode(dec, packet, len(packet) if packet else 0, out, SAMPLES, 1 if fec else 0)
assert n == SAMPLES, n
return list(out)

And where frame f is the one that never arrived:

python
elif recover == "fec":
out += decode(dec, packets[f + 1], fec=True)
elif recover == "plc":
out += decode(dec, None)
FEC has to be switched on at both ends

The receiver has to say it can use the copies, with useinbandfec=1 in the call setup, and RFC 7587 defaults that to 0. The sender’s encoder also has to be told how much loss to expect (OPUS_SET_PACKET_LOSS_PERC), because the copies cost bits: the Opus docs say a higher setting buys loss resistance “at the expense of quality at a given bitrate in the absence of packet loss”. Browsers do both for you; a server that speaks WebRTC itself has to do them on purpose.

What FEC buys, and when it doesn’t#

Back to the simulator, whose protection table puts the schemes side by side on the same network, and whose what-Alice-hears chart shows the same thing frame by frame. Its default is Opus FEC. On the random minute, FEC takes concealment from 4.5% of frames to 0.7%, and the longest gap from 40 ms to a single frame. That’s most of the trouble gone, for bits taken from inside the stream’s own bitrate.

With bursts, it does much less, and the reason is in the table above: FEC reaches back one packet. In a burst of three, packets 25, 26 and 27 are all gone, and the copies of 25 and 26 went down with them. Only 27 can be rebuilt, from packet 28. On the bursty minute FEC brings concealment from 3.7% to 1.5% and leaves a 160 ms gap. It still rebuilds more than one frame in three because not everything missing came in bursts: the 1.8% of packets that were merely late are scattered one at a time, and FEC rebuilds those just as it does on the random minute.

So reach further back with RED, two copies? Here’s the part that’s easiest to get wrong: at a 40 ms buffer, two copies recover exactly as much as one. The second copy of frame 25 rides in packet 27, which leaves 40 ms after frame 25 did, and then picks up its own extra delay on the way. With a 40 ms buffer it would have to arrive with no extra delay at all to make frame 25’s slot, and it essentially never does.

A timeline from frame 25 being sent. Its copy in packet 26 leaves 20 ms later and lands at 58 ms, before frame 25's slot at 70 ms with a 40 ms buffer. A second RED copy in packet 27 leaves 40 ms later and lands at 78 ms: too late for the 70 ms slot, but in time for the 110 ms slot an 80 ms buffer gives.
The copy’s trip here is illustrative, a 30 ms trip plus 8 ms of jitter. The 20 ms steps between packets are not: each copy reaching one packet further back leaves 20 ms later.
A copy is only as good as the buffer that waits for it

Reaching back k packets means the copy leaves 20k ms after its frame, so the buffer has to be deeper than 20k ms, by about the jitter, before the copy can help. FEC needs more than 20 ms of buffer to do anything (at exactly 20 ms the simulator’s FEC rebuilds 1 frame in 3,000); a second RED copy needs more than 40.

Give the buffer 80 ms and RED’s second copy starts earning its bandwidth: concealment on the bursty minute falls to 0.6%, against 1.0% for FEC at the same buffer. That’s the whole trade in one sentence: against bursts you can spend bandwidth and delay, or you can spend dropouts.

Try the samples in the simulator in order: Random loss, then Bursts of three, then Bursts, deeper buffer. Watch what Alice hears for runs of made-up frames in a row, and the protection table, which sets what each scheme recovers beside what it still leaves concealed.

The simulator is a model, and its numbers are its own: it gives FEC a copy on every packet, which is the most FEC can do (the demo showed real Opus skipping some), and its jitter is random packet to packet, where real queues tend to delay neighbours together. The tool’s page lists every assumption.

Why not just resend?#

Video calls do, and it’s worth seeing why voice mostly doesn’t. A receiver that notices a gap can send a NACK, an RTCP message naming the missing sequence number (RFC 4585 §6.2.1), and the sender resends the packet (RFC 4588).

Two sender-to-receiver timelines for lost frame 25, 30 ms each way and a 40 ms buffer, so frame 25 plays at 70 ms. Left, ask again: packet 26 arrives at 50 ms, the receiver sends a NACK asking for 25, the sender resends it, and it arrives at 114 ms, 44 ms after frame 25's slot. Right, carry a copy: packet 26 carries a copy of 25, arrives at 50 ms, and frame 25 is rebuilt 20 ms before its slot.
Asking costs the time to notice, a request back and the packet again. A copy costs bandwidth on every packet, whether or not it’s needed, and in exchange it’s already there.

The resend costs a round trip, and that’s the whole question: does a round trip fit inside the frame’s wait? With Alice’s 40 ms buffer and a 60 ms round trip, it doesn’t, and the resent frame arrives after its slot like any other late packet. The WebRTC transport guidelines make the same call: use retransmission “when the connection RTT is within the application’s latency budget” (RFC 8854 §8), and FEC when it isn’t.

MediaVideoVoice
What a lost packet breaksA frame, and every later frame that’s encoded as a change from it, until it’s repaired20 ms of sound, once
Cost of not repairingAsk for a keyframe, a whole picture sent from scratch, which is expensiveConceal 20 ms
libwebrtc’s defaultNACK offered on every video codecNACK not offered for audio

You can see that last row in libwebrtc’s own defaults: it offers nack on every video codec (codec.cc) and not on Opus (webrtc_voice_engine.cc). Audio NACK does exist if both ends negotiate it. But for a conversation, carrying the copy in advance is usually cheaper than asking for it afterwards.

What to watch in a real call#

Everything in this post shows up in the browser’s own counters. In Chrome, open chrome://webrtc-internals during a call, or call getStats() on the peer connection and read the inbound audio stream. The definitions are from the W3C webrtc-stats spec:

CounterWhat it tells you
jitterRTP’s running jitter estimate, in seconds
jitterBufferDelay ÷ jitterBufferEmittedCountThe average time a sample waited in the buffer: what the buffer is costing you
packetsLostPackets that never came. It “can be negative if more packets are received than sent”
packetsDiscardedPackets the jitter buffer threw away “due to late or early-arrival”: this post’s title, as a number
concealedSamplesSamples the decoder made up, for lost packets and for “packets that arrive too late”
concealmentEventsHow many separate concealed stretches there were. Few events with many samples means bursts
insertedSamplesForDeceleration, removedSamplesForAccelerationThe adaptive buffer stretching and squeezing the audio to move its target
fecPacketsReceivedFEC packets received, which the spec says can include Opus’s in-band copies

Here’s what that looks like for a real call. Two peer connections in one Chrome tab, one sending a generated tone to the other, after a couple of minutes, with the table filtered to the counters this post is about:

Chrome's webrtc-internals page, the inbound-rtp table for an Opus audio stream with useinbandfec=1 in its codec line: packetsReceived 5923, packetsLost 0, packetsDiscarded 0, concealedSamples 0, concealmentEvents 0, jitter 0.002, jitterBufferDelay 168134.4, jitterBufferEmittedCount 5618880, jitterBufferTargetDelay 113932.8, insertedSamplesForDeceleration 1962, removedSamplesForAcceleration 654, fecPacketsReceived 0.
A call that never leaves the machine, so nothing is lost or concealed. Two things still show. The codec line says useinbandfec=1: Chrome agreed to Opus FEC without being asked. And jitterBufferDelay ÷ jitterBufferEmittedCount is 168,134.4 ÷ 5,618,880, about 30 ms a sample, with the buffer still stretching and squeezing the audio around its target, which is what insertedSamplesForDeceleration and removedSamplesForAcceleration count.

concealedSamples divided by concealmentEvents is the number I’d look at first: it’s the average length of a gap, in samples, and at 48 kHz, 960 of them is one 20 ms frame.

You don’t have to wait for a bad call to watch these move. The lab below starts a real one in this tab: two WebRTC connections talking to each other, Opus over loopback, with Alice’s exchange as the voice, so no microphone is involved. (The voices are generated with Kokoro, an open text-to-speech model; switch Sound to Hum for a steady tone instead.) Each frame passes through this page on its way into Chrome’s jitter buffer, and the page can drop it or hold it back. Everything after that is Chrome’s own, and the counters are read from getStats() once a second.

A real call, in your browserlive lab

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

Three things worth trying, with Listen on, giving each about 15 seconds:

TryWatch
Lose 5%, bursts of 1Many short gaps: the average gap stays close to one 20 ms frame
Lose 5%, bursts of 5Fewer gaps, much longer ones: the average gap jumps to several frames
Lose 0%, hold back 5% by 250 msNothing is lost, and Thrown away for arriving late climbs anyway: late is lost, in Chrome’s own words

It needs Chrome or Edge, because it reaches into the call through an API only they ship. And it shows concealment, not FEC: the page drops frames after Chrome has received them, so the sender never learns of any loss and has no reason to send copies.

The demo#

The code is in late-is-lost/. It needs Python 3.12 or newer. PyAV brings libopus with it, and fec_recovery.py calls that same copy directly for the decoding:

Shell
Terminal window
cd mainlog-code/late-is-lost
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python fec_recovery.py

The sound is synthetic so every run gives the same numbers on the same libopus. The repository’s CI runs it on a different libopus build, so it checks the shape rather than the decimals: that the encoder still puts a copy in 28 of the 44 packets, and that where it does, FEC still beats concealment by more than 10 dB.

So the receiver’s job comes down to three dials: how long to wait, how to fill a gap, and how much to send twice. Every one of them spends something, delay, quality or bandwidth, and bursts make all three cost more. Next in the series, the packets have to find each other first: how two computers behind home routers get a path between them at all, with NAT, STUN, TURN and ICE.

Share this article

Related posts

3 items
  • 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
  • Sep 7, 2026 14 min read Networking & media

    More samples, same sound

    Converting audio to a higher sample rate makes it fit the next program; it cannot bring back sound an earlier step removed. Three test tones show why.

    Down to the wireaudioperformance
  • 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
  • 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
  • Networking & media

    More samples, same sound

    Converting audio to a higher sample rate makes it fit the next program; it cannot bring back sound an earlier step removed. Three test tones show why.

    audioperformance
    Sep 2026
  • 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

Comments