VP8 vs H.264 vs AV1 Codec Selection
Codec choice is the single decision that most directly shapes CPU budget, battery drain, bitrate efficiency, and cross-browser interoperability in a WebRTC session. Pick wrong and you ship sessions that thermal-throttle on mobile, fail to negotiate against Safari, or burn 30% more bandwidth than necessary. This guide is part of the Media Handling, Codecs & Bandwidth Estimation guide, and its goal is to give you a deterministic, capability-driven procedure for selecting and negotiating a video codec across Chrome, Firefox, and Safari, then degrading gracefully when the device or network can’t sustain your first choice.
The four codecs you will realistically negotiate over m=video today are VP8, VP9, H.264, and AV1. Each occupies a different point on the efficiency-versus-cost curve, and the “best” one is entirely a function of the device’s hardware decode support, the encoder’s CPU headroom, and whether your deployment can absorb H.264 patent licensing. The sections below walk through a comparison table, then a concrete four-step negotiation procedure built on getCapabilities() and setCodecPreferences(), the browser quirks that break naive implementations, and the mistakes that recur in production codebases.
Codec Comparison Matrix
The table below summarises the engineering trade-offs that drive selection. Compression is expressed relative to H.264 baseline at equivalent perceptual quality; CPU figures refer to software encode cost at 720p30; hardware decode reflects mainstream device availability as of 2026.
| Codec | Compression vs H.264 | Encode CPU (SW, 720p30) | Hardware decode | Browser support | Latency profile |
|---|---|---|---|---|---|
| VP8 | Baseline (≈0%) | Low — robust, predictable | Rare (mostly software) | Chrome, Firefox, Safari 12.1+ | Lowest; strong intra-refresh, resilient to loss |
| VP9 | ~30% better | Medium-high | Common on recent SoCs | Chrome, Firefox; Safari decode only | Low; SVC-friendly |
| H.264 | Reference | Low (almost always HW) | Ubiquitous (every modern device) | All browsers; only codec Safari hardware-encodes | Low, but I/P chains fragile under loss |
| AV1 | ~50% better (≈30% over VP9) | Very high (SW); HW encode rare | Growing (A17 Pro+, Snapdragon 8 Gen 2+) | Chrome 90+, Firefox; Safari decode on Apple silicon | Higher encode latency; excellent at low bitrate |
The practical reading of this table: AV1 wins on bytes but loses on encode cost unless you have hardware encode (Intel Arc, NVIDIA RTX 40-series, AMD RX 7000 on desktop, or specific mobile SoCs), and once you commit to it the temporal and spatial layer structure is a separate decision covered in Configuring AV1 SVC Layers in WebRTC. H.264 wins on universal hardware support and battery efficiency, which is why it is effectively mandatory for forcing H.264 hardware acceleration on Safari and iOS targets. VP8 remains the safe royalty-free baseline that always negotiates. VP9 sits in the middle as a software-friendly efficiency upgrade where AV1 encode is too expensive.
Three constraints sit behind this table and deserve explicit attention before you write a single line of negotiation code. The first is licensing. VP8, VP9, and AV1 are royalty-free; H.264 sits under a patent pool (Via LA, formerly MPEG LA) whose terms bite for commercial deployments that distribute encoded media at scale. For a peer-to-peer or SFU-relayed conferencing product the practical exposure is usually low, but the legal posture differs from the open codecs and should be a conscious choice rather than a default. The second constraint is error resilience: H.264 leans on long I/P (and optionally B) frame chains, so a single lost reference frame cascades into visible corruption across every dependent frame until the next keyframe. VP8 and AV1 use intra-refresh and stronger error concealment, which makes them markedly better on lossy or high-jitter links. The third is encode-versus-decode asymmetry — a device may hardware-decode a codec it cannot hardware-encode, and getCapabilities() does not distinguish the two, so telling the two apart needs the runtime probes described in Detecting Hardware vs Software Encoding. Treating “appears in capabilities” as “accelerated in both directions” is the root of most codec-selection regressions.
A useful mental model is to rank the codecs along two axes simultaneously: bytes-on-the-wire (AV1 < VP9 < VP8 ≈ H.264) and CPU-per-frame (H.264 ≪ VP8 < VP9 ≪ AV1 in software). Your selection logic is essentially a search for the most byte-efficient codec whose CPU cost the current device can actually sustain at your target frame rate and resolution. On an 8-core laptop with a discrete GPU that is AV1; on a three-year-old phone it is H.264; on an unknown Chromium device with no GPU signal it is VP9 or VP8. The four steps below turn that judgement into deterministic code.
Why AV1’s Efficiency Costs So Much Encode CPU
The 50%-over-H.264 figure is not magic, and understanding where it comes from tells you exactly when you will and will not get it. AV1 buys its bitrate savings by massively enlarging the encoder’s search space: superblocks go up to 128×128 with recursive partitioning down to 4×4, there are 56 directional intra prediction modes plus palette and intra block copy tools aimed at screen content, motion compensation supports compound prediction combining two references with a learned mask, and post-processing adds constrained directional enhancement and loop restoration filters that the encoder must model while deciding. Every one of those tools is another branch in a rate-distortion optimisation loop. A file-based encode can afford to explore them exhaustively; a real-time encoder with a 33 ms frame budget at 30 fps cannot.
This is why the codec’s advertised gain and its gain inside WebRTC are different numbers. Browsers run libaom in realtime mode at high speed presets (roughly cpu-used 7–10), which disables or heavily prunes exactly the tools that produce the headline compression. Measured against H.264 at realtime settings you should plan for 20–30% savings, not 50% — still meaningful, but the decision changes when the cost is a doubled or tripled encode time on a device that has no hardware encoder. Decode is far less affected, since a decoder only executes the tools the bitstream actually used; that asymmetry is why AV1 decode shipped on mainstream silicon years before AV1 encode did, and why receiving AV1 from a hardware-encoding desktop peer is usually safe on a mid-range phone that could never encode it.
Bitrate Targets and What Each Codec Buys You
Codec efficiency only becomes real once you translate it into a target bitrate the congestion controller is allowed to spend. The figures below are working targets for a talking-head camera stream at the stated resolution and 30 fps, with normal motion; screen content with static regions runs well below them, and high-motion content runs 30–50% above.
| Target | H.264 | VP8 | VP9 | AV1 (realtime) |
|---|---|---|---|---|
| 360p30 | 500–700 kbps | 450–650 kbps | 350–500 kbps | 280–420 kbps |
| 720p30 | 1.4–2.2 Mbps | 1.3–2.0 Mbps | 1.0–1.5 Mbps | 0.8–1.2 Mbps |
| 1080p30 | 3.0–4.5 Mbps | 2.8–4.2 Mbps | 2.0–3.2 Mbps | 1.6–2.6 Mbps |
Read these as the point where each codec stops looking obviously degraded, not as a floor. The practical use is sizing: a three-layer simulcast ladder at 720p/360p/180p using VP8 needs roughly 2.6 Mbps of aggregate uplink, while the same ladder on VP9 needs about 1.9 Mbps — the difference between fitting and not fitting inside a 2 Mbps ADSL uplink that also carries a 24–32 kbps Opus stream and a data channel. Below roughly 300 kbps the ordering matters more than anywhere else, because AV1 and VP9 hold structure where H.264 at Constrained Baseline collapses into blocking; that is the regime where switching codec buys more than any encoder parameter you can tune.
Step 1 — Detect Capabilities with getCapabilities
Never hardcode payload types or assume a codec exists. Payload type numbers are assigned per session and differ between browsers, and the codec set varies by platform — Safari has historically advertised H.264 only, while Chrome exposes AV1, VP9, VP8, and H.264. Start every negotiation by enumerating what the local endpoint can actually do.
// getCapabilities is static — call it without a peer connection.
// It returns the union of codecs the browser can encode/decode.
const caps = RTCRtpSender.getCapabilities('video');
// Each codec entry: { mimeType, clockRate, sdpFmtpLine, channels }
// H.264 entries differ by sdpFmtpLine (profile-level-id / packetization-mode).
const available = caps.codecs.map(c => ({
mime: c.mimeType, // e.g. "video/H264"
fmtp: c.sdpFmtpLine ?? '' // e.g. "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"
}));
console.table(available); // inspect before building a preference list
The sdpFmtpLine field matters most for H.264: a single video/H264 mimeType may appear several times, one per profile. Selecting the wrong profile entry is the most common cause of silent negotiation failure, which is covered in depth in forcing H.264 hardware acceleration on Safari.
Step 2 — Build a Preference Order with setCodecPreferences
Browser defaults rarely match production intent. Chrome, for example, may list VP8 ahead of AV1. Override the m=video ordering with RTCRtpTransceiver.setCodecPreferences(), which must be called on the transceiver before createOffer(). It has no effect once negotiation has begun.
const pc = new RTCPeerConnection();
const transceiver = pc.addTransceiver(videoTrack, { direction: 'sendrecv' });
const caps = RTCRtpSender.getCapabilities('video');
// Priority intent: best efficiency first, universal fallback last.
const preferredMimeTypes = ['video/AV1', 'video/VP9', 'video/VP8', 'video/H264'];
// flatMap preserves your priority order while dropping unsupported codecs.
const ordered = preferredMimeTypes.flatMap(
mime => caps.codecs.filter(c => c.mimeType === mime)
);
transceiver.setCodecPreferences(ordered); // MUST precede createOffer()
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
Keep at least one royalty-free codec (VP8) at the tail of every preference list so the offer never produces an empty intersection with a constrained remote endpoint. Codec ordering interacts with track lifecycle: constraints must already be bound to the track, which ties into Audio/Video Track Management when you replace or re-add tracks after the initial offer.
Keep RTX, RED and FEC in the Preference List
getCapabilities('video').codecs does not only contain media codecs. It also contains the auxiliary payload types that make loss recovery work: video/rtx (retransmissions, one entry per media payload type via its apt fmtp parameter), video/red and video/ulpfec on Chrome, and video/flexfec-03 where enabled. A filter written as “keep the mime types I care about” silently deletes all of them, and setCodecPreferences() treats the array you hand it as the complete allowed set — anything absent is stripped from the offer. The result is an m-section with no a=rtpmap:… rtx/90000 lines, which means NACK requests can be sent but nothing can be retransmitted in response. Video then survives clean networks and falls apart at 2–3% loss, with nackCount climbing in getStats() while retransmittedPacketsSent stays at zero on the sender.
// Build the media-codec order first, then re-attach everything else.
const caps = RTCRtpSender.getCapabilities('video');
const mediaOrder = ['video/AV1', 'video/VP9', 'video/VP8', 'video/H264'];
const isMedia = c => mediaOrder.includes(c.mimeType); // the four real codecs
const chosen = mediaOrder.flatMap(m => caps.codecs.filter(c => c.mimeType === m));
// Everything that is not a media codec — rtx, red, ulpfec, flexfec — must survive.
// Order among these does not matter; presence does.
const auxiliary = caps.codecs.filter(c => !isMedia(c));
transceiver.setCodecPreferences([...chosen, ...auxiliary]);
If you genuinely want to drop a codec (say, excluding H.264 on a royalty-free-only deployment), drop only its media entry and let the browser reconcile the now-dangling rtx entry, which it does correctly — the reverse mistake, keeping H.264 but dropping its rtx companion, is the one that produces mysterious loss sensitivity.
Codec Choice Under Simulcast and Screen Sharing
Preference order is not independent of how you plan to scale the stream. VP8, VP9 and H.264 all support classic simulcast — three independently encoded sendEncodings entries at 1/1, 1/2 and 1/4 scale — but the encode cost multiplies with each layer, so a VP9 three-layer ladder can cost more CPU than a single AV1 stream on the same machine. AV1 in Chrome is driven the other way, through scalabilityMode (L3T3_KEY and friends) on a single encoding, because temporal and spatial layering inside one AV1 bitstream is cheaper than encoding three of them; the layer geometry and the SFU-side consequences are laid out in Simulcast & SVC Implementation. Practically this means a preference list containing AV1 must be paired with encoding parameters that match, or you will negotiate AV1 and then hand it a three-entry simulcast array that Chrome collapses to a single layer without warning.
Screen sharing inverts several of the table’s assumptions. Static text and UI compress far better than camera video, so the byte advantage of AV1 shrinks while its per-frame cost stays; VP9 and AV1 both carry screen-content tools (palette mode, intra block copy) that H.264 Constrained Baseline lacks, which is why shared text stays legible on VP9 at bitrates where H.264 turns it to mush. Combine the codec decision with the track’s contentHint, since that is what tells the encoder to preserve detail rather than frame rate — see Keeping Shared Text Readable with contentHint for the exact settings.
Step 3 — Validate fmtp and Negotiated Codecs
Setting preferences does not guarantee the codec survives the answer. Validate the negotiated SDP, focusing on H.264 profile-level-id and packetization-mode, because mismatches there are rejected silently during the answer phase rather than raising an error.
// After setLocalDescription, confirm the m=video line ordering and fmtp.
function validateH264Profile(sdp) {
// profile-level-id is a 6-hex-digit value: profile_idc + constraints + level_idc
const m = sdp.match(/a=fmtp:\d+ [^\n]*profile-level-id=([0-9a-fA-F]{6})/);
if (!m) return false;
const profileIdc = parseInt(m[1].slice(0, 2), 16);
// 0x42 Constrained Baseline, 0x4D Main, 0x64 High.
// 42e01f (Constrained Baseline, level 3.1) is the safest interop target.
return profileIdc <= 0x42;
}
// Also confirm feedback machinery survived: transport-cc + nack are required
// for stable bitrate adaptation regardless of which codec won.
const hasTwcc = pc.localDescription.sdp.includes('transport-cc');
Equally important: confirm a=rtcp-fb lines carry transport-cc and nack for the chosen codec. Without them the congestion controller falls back to loss-only signals — align this with Bandwidth Estimation & Congestion Control so the encoder output tracks real network capacity, and check which feedback dialect actually survived the answer using Transport-CC vs REMB Feedback.
Step 4 — Implement a Capability-Driven Fallback
Static configurations break across fragmented device ecosystems. Construct the preference order at runtime from navigator.hardwareConcurrency, the presence of AV1 in capabilities, and the target browser, then monitor encoder load and renegotiate to a lighter codec if the device cannot sustain the first choice.
function buildPreferenceOrder(caps, { isSafari, cores }) {
const has = mime => caps.codecs.some(c => c.mimeType === mime);
let order;
if (isSafari) {
// Safari hardware-encodes only H.264; everything else is software.
order = ['video/H264', 'video/VP8'];
} else if (has('video/AV1') && cores >= 8) {
order = ['video/AV1', 'video/VP9', 'video/VP8', 'video/H264'];
} else {
order = ['video/VP9', 'video/VP8', 'video/H264']; // SW-friendly path
}
return order.flatMap(mime => caps.codecs.filter(c => c.mimeType === mime));
}
// Watch encoder cost: if per-frame encode time blows the frame budget,
// renegotiate down. 33 ms is the budget for a 30 fps target.
async function encoderOverloaded(pc) {
for (const r of (await pc.getStats()).values()) {
if (r.type === 'outbound-rtp' && r.kind === 'video' && r.framesEncoded > 0) {
const perFrame = (r.totalEncodeTime / r.framesEncoded) * 1000; // ms
if (perFrame > 33) return true; // sustained → drop to a lighter codec
}
}
return false;
}
The full mid-session switch — re-ordering preferences, generating a fresh offer, and requesting a keyframe — is the subject of dynamically switching video codecs based on client capabilities, which maintains session continuity across the renegotiation window.
Two refinements make this fallback production-grade. First, debounce the trigger: require the overload or loss condition to persist across at least two consecutive getStats() polls (poll at 1000–2000 ms intervals to match the rest of your telemetry) before renegotiating, because a single noisy sample during a transient CPU spike or a brief jitter burst should never cause a codec switch. Second, make the fallback monotonic within a session unless conditions clearly recover — bouncing AV1 → VP8 → AV1 → VP8 in rapid succession interrupts media far more than staying on the lighter codec would. A practical policy is to step down promptly on sustained overload but step back up only after a longer stable window (for example 15–20 s of healthy stats), which mirrors the 15–20% bandwidth-delta hysteresis used for simulcast layer switching. Carry the current codec and the consecutive-sample counter in a small state object so the trigger function is idempotent and never re-issues an offer for a codec that is already active. Before paying for a renegotiation at all, consider the cheaper lever: letting the encoder shed resolution or frame rate under the same overload, which is the trade-off analysed in degradationPreference: Resolution vs Framerate.
Edge Cases & Browser Quirks
- Safari (16–17, iOS): historically advertises H.264 only over
getCapabilities('video'), and hardware-encodes nothing else. AV1 and VP9 appear as decode-only on Apple silicon. Always include H.264 with a Constrained Baselineprofile-level-id=42e01fandpacketization-mode=1, or the offer will not interoperate. - Chrome (90+): lists AV1 but software-encodes it unless a supported GPU is present; forcing AV1 on a mid-range laptop drives
totalEncodeTimeup and triggers frame drops. Chrome also caps concurrent hardware encoders per tab — saturating the GPU queue silently degrades a second stream to software. - Firefox: exposes AV1 and VP9 but lags Chrome on hardware AV1 encode. Payload type numbers differ from Chrome’s for identical codecs, so never compare or reuse PT values across browsers.
setCodecPreferencestiming: calling it aftercreateOffer(), or on a stopped transceiver, is a no-op or throwsInvalidStateError. Re-apply it before every renegotiation, not just the first.- Empty-array reset:
setCodecPreferences([])resets to browser defaults rather than disabling video — a frequent accidental footgun when a filter produces no matches. - Firefox H.264 availability is not static: desktop Firefox has historically obtained its H.264 encoder from the OpenH264 GMP plugin, downloaded in the background after first run. On a fresh profile, an enterprise policy that blocks GMP downloads, or a machine that has never had network access to the update service,
video/H264is simply absent fromgetCapabilities()— the same browser build on the next machine advertises it. Never cache a capability snapshot across sessions, and re-read capabilities on every page load. - Mid-call hardware-encoder eviction: hardware H.264 and VP9 encoders have resolution and alignment constraints (typically even dimensions, and a minimum around 176×144). When adaptation shrinks the stream past a boundary, or a
replaceTrack()swaps in a source with odd dimensions, Chrome tears down the hardware encoder and continues in software with no codec change and no event — the only visible symptom istotalEncodeTimeper frame jumping several-fold whileencoderImplementationflips from an external encoder name tolibvpxorOpenH264. Poll that field rather than assuming acceleration persists. - AV1 and older Android: Chrome on Android exposes AV1 decode broadly but hardware encode only on very recent SoCs. A
navigator.hardwareConcurrency >= 8check passes on plenty of mid-range Android phones with eight small-and-medium cores that will still miss the frame budget, so treat core count as a weak signal on mobile and require an explicit hardware-encode confirmation before preferring AV1 there.
Confirming Which Encoder Actually Ran
Capability lists and negotiated SDP tell you what was agreed, not what executed. Two getStats() fields on outbound-rtp close that gap: encoderImplementation, a free-form string that names the concrete encoder ("ExternalEncoder", "libvpx", "OpenH264", or "SimulcastEncoderAdapter (libvpx, libvpx, libvpx)" when three software layers are running), and powerEfficientEncoder, a boolean exposed by recent Chrome that reports the browser’s own view of whether the pipeline is accelerated. Log both at session start and on every codec change, because they are the only signals that distinguish “AV1 negotiated and running on the GPU” from “AV1 negotiated and melting the CPU” — two states with identical SDP.
The same information appears live in Chrome’s internal dump, where the per-stream graphs of encode time and frames-per-second alongside the encoder implementation string make an overload obvious within a few seconds; Reading chrome://webrtc-internals Dumps covers how to navigate those panels and export a dump for offline analysis. When comparing candidate codecs on a target device, run the same source clip through each for 60 s and record mean per-frame encode time, framesDropped, and the achieved bitrate against the target — that three-number comparison settles arguments that spec sheets cannot.
Common Implementation Mistakes
- Hardcoding payload types instead of reading them from
getCapabilities()— PTs are session- and browser-specific and will mismatch. - Forcing AV1 without CPU telemetry — on low-end mobile this stalls the encoder, drops frames, and drains battery. Gate AV1 behind a hardware-encode or core-count check.
- Ignoring H.264 fmtp — offering High profile (
64001f) to a Baseline-only peer (42e01f) yields a silent rejection during the answer; the m-line survives but video never flows. - Omitting
transport-cc/nackfrom the kept codecs, leaving the bitrate estimator on loss signals only. - Renegotiating on transient jitter — back-to-back codec switches interrupt media. Require a sustained threshold (for example >5% loss for >2 s) before switching.
- Assuming a codec in capabilities means hardware acceleration — the API exposes support, not acceleration. Infer hardware encode indirectly from low
totalEncodeTimeat high resolution. - Stripping
rtx/red/ulpfecwhile filtering codecs — the preference array is the allowed set, so auxiliary payload types you forget to re-append disappear from the offer and retransmission stops working. - Deriving the preference order from the user-agent string — browser sniffing misclassifies Chromium shells, WKWebView-based iOS browsers, and Electron builds. Branch on what
getCapabilities()and encoder telemetry report, and use the user agent only for the one thing it reliably indicates (an Apple media stack that hardware-encodes H.264 alone). - Applying preferences to only one transceiver — a call with a camera transceiver and a screen-share transceiver needs the ordering applied to each, and re-applied to any transceiver created later by renegotiation.
FAQ
Does WebRTC automatically pick the best codec for each peer?
No. The browser applies its own default ordering, which often favours legacy or software codecs. Use setCodecPreferences() before the first offer to enforce a hardware-accelerated or bandwidth-optimised choice.
Can I change codecs mid-call without dropping the connection?
Yes, but only via a full SDP renegotiation — never setParameters(), whose codecs field is read-only. Re-order preferences, create a new offer, exchange it, and request a keyframe. Expect a brief pause of roughly one keyframe interval (1–4 s).
Why does H.264 fail to negotiate despite universal support?
Almost always a profile-level-id or packetization-mode mismatch between offer and answer. Align both endpoints on Constrained Baseline 42e01f with packetization-mode=1, which is the safest interop target across Chrome, Firefox, and Safari.
Should I default to AV1 in 2026?
Only when hardware encode is present. AV1 saves roughly 30% over VP9 and 50% over H.264, but software encode is too costly for sustained real-time use on most devices. Detect hardware first, then prefer AV1; otherwise prefer VP9 or H.264.
How do I keep a codec switch from interrupting the call?
Treat every switch as a full renegotiation that costs roughly one keyframe interval (1–4 s) of visible pause, then minimise how often you pay it: debounce the trigger across at least two getStats() polls, step down quickly but step back up only after 15–20 s of healthy stats, and request a keyframe the moment the new codec is active so the remote decoder unfreezes promptly.
Do send and receive use the same codec preferences?
setCodecPreferences() operates on the transceiver, so it rewrites the codec list of one whole m= section, which governs both directions of that section. What differs is the underlying capability set: RTCRtpSender.getCapabilities('video') and RTCRtpReceiver.getCapabilities('video') are not always identical, and the receive list is usually the larger of the two on devices that decode more than they encode. Build the preference array from the intersection when you care about both directions, or from the sender list when you are the only one sending — offering a codec you can decode but not encode is legal and often useful, since it lets a hardware-encoding peer send you AV1 while you send back VP8.
How does a media server change the codec decision?
A forwarding server does not transcode; it copies RTP packets between participants at roughly 1–3 ms of added latency, which means every subscriber must be able to decode whatever the publisher encoded. One participant on a device that only supports H.264 therefore pulls the entire room to H.264 unless the publisher is willing to encode a second stream. That is the real cost model behind codec selection at scale, and it is why rooms that must admit arbitrary clients tend to standardise on VP8 or H.264 while closed deployments with known hardware can exploit AV1 — see Media Server Architecture: SFU & MCU for how the forwarding path constrains the choice.
Related: return to Media Handling, Codecs & Bandwidth Estimation for the broader media pipeline, and continue with dynamically switching video codecs based on client capabilities and forcing H.264 hardware acceleration on Safari, or align encoder output with the network via Adaptive Bitrate Streaming in WebRTC.