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.

Codec constraint matrix Grid with one row per codec and four columns — licensing, loss resilience, hardware encode availability and hardware decode availability — showing where each codec is strong or weak. Three constraints behind the table, per codec Codec Licensing Loss resilience HW encode HW decode VP8 royalty-free no pool exposure strong intra-refresh rare — software rare — software VP9 royalty-free SVC-friendly medium some desktop GPUs common on new SoCs H.264 patent pool licence at scale weak I/P chain cascades everywhere everywhere AV1 royalty-free strong rare — costly in SW growing, newest only
Licensing, loss behaviour and the encode/decode asymmetry differ per codec — capabilities alone never tell you which column you are in.

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.

Codec decision matrix Decision flow from capability detection through hardware checks to a preferred codec order, with a software fallback path. getCapabilities('video') enumerate supported codecs AV1 HW encode? desktop GPU / new SoC Safari / iOS target? prefer H.264 HW CPU headroom? cores + encode time H.264 → VP8 AV1 → VP9 → VP8 VP9 → VP8 → H.264
Capability detection feeds a hardware-and-target decision that yields a preferred codec order with a VP8 software fallback.

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.

Annotated m=video SDP block An m=video section with rtpmap, fmtp and rtcp-fb lines, annotated to show that payload-type order encodes preference, that profile-level-id decomposes into profile, constraints and level, and that feedback lines repeat per payload type. What to read in the negotiated m=video block m=video 9 UDP/TLS/RTP/SAVPF 45 98 96 a=rtpmap:45 AV1/90000 a=rtpmap:98 VP9/90000 a=rtpmap:96 H264/90000 a=fmtp:96 level-asymmetry-allowed=1; packetization-mode=1;profile-level-id=42e01f a=rtcp-fb:45 transport-cc a=rtcp-fb:45 nack a=rtcp-fb:96 ccm fir PT order = preference order first mutually supported PT wins profile-level-id = 42 e0 1f 42 = Constrained Baseline e0 = constraint flags 1f = level 3.1 Feedback repeats per PT missing transport-cc leaves the estimator on loss only
Three things to verify in the answer: payload-type order, the H.264 fmtp fields, and that feedback lines survived for the winning payload type.

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.

Codec fallback state machine Four states — steady, overload watch, stepped down and cooldown — with transitions driven by consecutive getStats samples, a renegotiation with keyframe request, and a guarded recovery back to the preferred codec. Debounced codec fallback loop (1 s getStats polls) STEADY preferred codec active counter = 0 OVERLOAD WATCH encode time over budget counter = 1 STEPPED DOWN lighter codec negotiated keyframe requested COOLDOWN watching for 15–20 s of healthy stats poll shows > 33 ms per encoded frame one clean poll → reset counter, no switch 2nd bad poll → re-order prefs, new offer media flowing again on the lighter codec step back up only after a stable window
Two consecutive bad polls are required to step down; a single clean sample resets the counter, and recovery waits out a 15–20 s stable window.

Edge Cases & Browser Quirks

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

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.