Detecting Hardware vs Software Encoding

A browser will happily start a call on the GPU’s encode block and finish it on libvpx without firing an event, rejecting a promise, or writing anything to the console. This guide is part of the VP8 vs H.264 vs AV1 Codec Selection guide, and it answers one question at runtime: is this sender’s video actually being encoded in silicon right now, and if it stopped being, how fast can your client tell? The only honest evidence is the encoderImplementation string and the powerEfficientEncoder boolean on the outbound-rtp report, cross-checked against the encoder’s own timing counters.

Context & Trade-offs

There are two ways to ask the question and they answer different things. navigator.mediaCapabilities.encodingInfo() is a pre-flight query: you hand it a codec, resolution and framerate, and it returns supported, smooth and powerEfficient. It is cheap, it runs before any peer connection exists, and it is the right tool for choosing a capture profile at join time. What it cannot know is how many encode sessions the GPU is already running, whether the driver has been blocklisted since boot, or whether the laptop just went on battery. It describes the machine’s capability, not this call’s reality.

getStats() answers the second question. Every outbound-rtp video report carries encoderImplementation, a free-form string the browser fills in from whichever encoder the factory actually instantiated, and modern Chromium also exposes powerEfficientEncoder, a boolean that is the closest thing to a normative signal in the whole area. The strings are not standardised and they change between milestones, so treat them as a heuristic that confirms the boolean rather than as a parseable protocol.

Platform Typical hardware string Typical software string
Chrome / Windows MediaFoundationVideoEncodeAccelerator, older ExternalEncoder libvpx, OpenH264, libaom
Chrome / macOS VideoToolbox, older ExternalEncoder libvpx, OpenH264
Chrome / Linux VaapiVideoEncodeAccelerator libvpx, libaom
Chrome / Android ExternalEncoder (MediaCodec-backed) libvpx
Safari 16+ VideoToolbox for H.264 libwebrtc
Firefox 113+ often absent or coarse libvpx

Simulcast complicates the string rather than the meaning. Chrome wraps the per-layer encoders and reports something like SimulcastEncoderAdapter (ExternalEncoder, libvpx, libvpx) — the top layer landed on the accelerator and the two scaled-down layers did not, because the block’s minimum width rejected them. A naive equality test against ExternalEncoder reads that as software; a naive substring test reads it as hardware. Neither is right: it is a mixed pipeline, and the CPU cost is real but bounded.

Where a hardware encode silently becomes a software encode A capture track reaches the encoder factory, which tests whether an accelerator claims this size, profile and a free session slot. Passing routes to VideoToolbox or Media Foundation; failing routes to libvpx or OpenH264. Four gate conditions force the software path and none of them raise an error. Where a hardware encode silently becomes a software encode MediaStreamTrack 1280×720 at 30 fps Encoder factory CreateVideoEncoder Accelerator claims this size, profile and a free slot? yes no Hardware block VideoToolbox / MFT Software fallback libvpx / OpenH264 Gates that route to software, silently: Session cap reached 3–4 hardware encoders per browser process Odd geometry width not a multiple of 16, or 1.5× scaling No block for codec AV1 or VP9 profile 2 on older silicon Driver blocklisted one GPU crash kills accel for the session Any one gate is enough, and all four can trip mid-call as well as at setup. Runtime evidence: outbound-rtp.encoderImplementation + powerEfficientEncoder No event fires. The string simply reads differently on the next getStats() poll.
The factory decides once per encoder instance, and re-decides after any resolution change.

The asymmetry between encode and decode is worth internalising before you write any detection code. Decode blocks are broad and generous: nearly every device made in the last decade decodes H.264 in silicon, and VP9 and AV1 decode blocks are now common. Encode blocks are narrow. They are tuned for a handful of profiles at a handful of geometries, they are a finite resource shared across the whole browser process, and they are the first thing a graphics driver surrenders when it is under pressure. That is why a call can decode four remote participants without touching the CPU while its own single outbound stream quietly saturates a core.

Three triggers account for most production fallbacks, and none of them are visible from the signaling layer. The first is contention: a browser process holds a small number of hardware encode sessions, and a user who opens a second call, a screen recorder, or a game will take one. The second is power policy — several platforms disable the encode accelerator on battery saver, so the same laptop that encoded in silicon at 90% charge encodes in libvpx at 15%. The third is the driver blocklist: after a GPU process crash Chromium disables acceleration for the remainder of the browser session, so the fallback survives page reloads and looks, from the client’s point of view, like a machine that never had hardware at all.

The cost of being wrong is quantifiable. On a mid-range laptop a 720p30 VP8 encode costs roughly 1–3 ms of CPU per frame in silicon and 15–30 ms in libvpx; three simulcast layers in software can occupy an entire core. Once the encoder cannot hit its deadline, qualityLimitationReason flips to cpu and the pipeline starts shedding resolution or framerate according to your degradationPreference: Resolution vs Framerate setting. The user reports “the video got blurry”; the network was never involved.

Minimal Runnable Implementation

The classifier below does three things in one pass: unwraps the simulcast adapter string, prefers the boolean over the string when both are present, and derives per-frame encode time from the monotonic totalEncodeTime and framesEncoded counters so it can flag a degradation the string has not yet admitted to.

// Strings are unstable across milestones — match families, not exact values.
const ACCEL = /ExternalEncoder|VideoToolbox|MediaFoundation|Vaapi|MediaCodec|NvEnc|QuickSync/i;
const SOFT  = /libvpx|OpenH264|libaom|SVT-AV1|libwebrtc/i;

// "SimulcastEncoderAdapter (ExternalEncoder, libvpx, libvpx)" → per-layer verdicts.
function classifyImplementation(impl, powerEfficient) {
  if (typeof powerEfficient === 'boolean') {
    // Normative when present; the string is then only useful for logging.
    return { verdict: powerEfficient ? 'hardware' : 'software', source: 'boolean' };
  }
  if (!impl || impl === 'unknown') return { verdict: 'unknown', source: 'absent' };
  const inner = impl.match(/\(([^)]*)\)/);              // adapter wrapper, if any
  const parts = inner ? inner[1].split(',').map(s => s.trim()) : [impl];
  const hw = parts.filter(p => ACCEL.test(p)).length;
  const sw = parts.filter(p => SOFT.test(p)).length;
  if (hw && !sw) return { verdict: 'hardware', source: 'string' };
  if (sw && !hw) return { verdict: 'software', source: 'string' };
  if (hw && sw)  return { verdict: 'mixed', layers: parts, source: 'string' };
  return { verdict: 'unknown', source: 'string' };      // unrecognised vendor string
}

const prev = new Map();                                  // ssrc → last sample

// Poll at 1 s; anything faster just adds noise to the encode-time derivative.
async function sampleEncoder(sender) {
  const report = await sender.getStats();
  const out = [];
  report.forEach(s => {
    if (s.type !== 'outbound-rtp' || s.kind !== 'video') return;
    const last = prev.get(s.ssrc);
    prev.set(s.ssrc, s);
    if (!last) return;                                   // need a delta, skip first tick
    const dFrames = s.framesEncoded - last.framesEncoded;
    if (dFrames <= 0) return;                            // sender paused or layer inactive
    // totalEncodeTime is cumulative seconds; convert the delta to ms per frame.
    const msPerFrame = ((s.totalEncodeTime - last.totalEncodeTime) / dFrames) * 1000;
    out.push({
      rid: s.rid ?? 'single',
      ...classifyImplementation(s.encoderImplementation, s.powerEfficientEncoder),
      msPerFrame: +msPerFrame.toFixed(2),
      fps: s.framesPerSecond,
      limitedBy: s.qualityLimitationReason                // 'none' | 'cpu' | 'bandwidth'
    });
  });
  return out;
}

Two details are load-bearing. First, the sample is taken from sender.getStats() rather than pc.getStats(), which keeps the report to one sender’s transceiver and avoids scanning every inbound stream on a large call. Second, framesEncoded must gate the division: a suspended simulcast layer keeps its counters frozen, and dividing by a zero delta produces an Infinity that will trip every threshold you own. The same discipline applies to reading the neighbouring congestion fields, covered in Interpreting getStats() for Congestion Signals.

A silent fallback in three signals Across a twenty second window sampled once per second, per-frame encode time jumps from about 1.4 milliseconds to 18 to 26 milliseconds at t equals eight, framerate collapses from 30 to between 11 and 14, and the encoderImplementation string changes from ExternalEncoder to a simulcast adapter of libvpx instances. A silent fallback in three signals, 1 s getStats polling t = 8 s: driver reset, no event fired encode ms per frame 1.4 ms 18–26 ms frames per second 30 fps 11–14 fps encoder implementation ExternalEncoder SimulcastEncoderAdapter (libvpx, libvpx) 0 s 4 s 8 s 12 s 16 s 20 s Encode time and framerate move first; the string only confirms what the counters showed.
Three independent signals from the same report, sampled at 1 s.

Cadence matters as much as the classifier. Poll at 1 s for the first 15 seconds of a sender’s life, when the factory decision is fresh and a bad geometry will show up immediately, then back off to one sample every 5 s for the rest of the call to keep the stats call off the hot path. Force an immediate re-sample after three events that always rebuild the encoder: a replaceTrack(), a setParameters() that changes scaleResolutionDownBy or maxFramerate, and any renegotiation that alters the codec. The verdict from before one of those events is stale, and treating it as current is how a client ends up reporting hardware encode for a stream that has been on libvpx for ten minutes.

Reproduction Steps & Debugging Log Patterns

  1. Establish a baseline. Open a one-to-one call at 1280×720, run sampleEncoder() for ten ticks, and record the median msPerFrame and the raw string. On a machine with a working accelerator you should see single-digit milliseconds and an accelerator family name.
  2. Force the session cap. Open three or four more tabs each publishing 720p video from the same profile. Chrome exhausts its per-process hardware encoder slots and the newest sender is constructed on libvpx — with no error anywhere in the call setup path.
  3. Force a geometry rejection. Set scaleResolutionDownBy: 1.5 on a simulcast encoding so the middle layer lands on a non-aligned width; the adapter string will show a hardware entry alongside software entries. The layer arithmetic behind this is in Simulcast with Three Quality Layers in Chrome.
  4. Force a mid-call fallback. Start hardware-backed, then launch a GPU-heavy application until the driver resets. The encoder is rebuilt in software while the peer connection, the SSRC and the SDP all stay untouched.
  5. Confirm in the dump. Cross-check the transition timestamp in chrome://webrtc-internals/, where encoderImplementation appears as a text row on the outbound stream — the technique is described in Reading chrome://webrtc-internals Dumps.
// Healthy hardware trace (1 s polling, single encoding):
// [t+3s] rid=single hardware  msPerFrame=1.42 fps=30 limitedBy=none
// [t+4s] rid=single hardware  msPerFrame=1.51 fps=30 limitedBy=none

// Mixed simulcast — top layer in silicon, scaled layers in libvpx:
// [t+5s] rid=f mixed layers=[ExternalEncoder,libvpx,libvpx] msPerFrame=6.80 fps=30

// Silent fallback at t+8s — note the SSRC and the codec never change:
// [t+8s]  rid=single hardware  msPerFrame=1.48  fps=30  limitedBy=none
// [t+9s]  rid=single software  msPerFrame=21.60 fps=22  limitedBy=cpu
// [t+10s] rid=single software  msPerFrame=24.10 fps=13  limitedBy=cpu

The ordering in that trace is the diagnostic. A limitedBy=cpu that appears with a string change is an encoder fallback; a limitedBy=cpu with the string unchanged is ordinary encoder overload — more layers or more pixels than the same pipeline can carry — and lowering the layer count fixes it. Conversely, if msPerFrame climbs while the string still reports an accelerator, suspect a shared GPU under contention rather than a fallback.

Runtime classifier: four states, four guards The classifier starts in unknown until thirty frames are encoded, moves to hardware confirmed when the string matches an accelerator family and powerEfficientEncoder is true, moves to suspect when per-frame encode time exceeds three times baseline for two polls, returns to hardware confirmed if encode time recovers, and commits to software confirmed when the string flips or five consecutive polls stay degraded. Runtime classifier: four states, four guards unknown fewer than 30 frames encoded so far string in accelerator family or powerEfficientEncoder true hardware confirmed baseline msPerFrame recorded and frozen per-frame encode time above 3× baseline, 2 polls encode time recovers suspect numbers degraded, string not yet changed string flips to libvpx or OpenH264, or 5 consecutive degraded polls software confirmed act now: the encoder will not recover alone Reaction: cut to two layers, cap at 720p30, or renegotiate to the codec with a block.
Hysteresis matters: two polls to suspect, five to commit, one clean poll to recover.

Common Implementation Mistakes

FAQ

Is powerEfficientEncoder reliable enough to use alone?

Where it exists, yes — it is the field the specification intends for this purpose and it does not suffer from string drift. But it is absent in Firefox and in older Chromium builds, so the practical answer is a classifier that prefers the boolean, falls back to the string family match, and reports unknown rather than guessing when it has neither.

What should the client actually do after confirming a software fallback?

Reduce the work before the encoder starts dropping it for you: cut three simulcast layers to two, cap capture at 720p30, and if the peer supports it, switch to a codec the device does have a block for. That last step is a renegotiation, so drive it through the flow in Dynamically Switching Video Codecs rather than mutating parameters mid-flight.

Does the same technique work for decode?

Yes, with different field names: inbound-rtp reports carry decoderImplementation and powerEfficientDecoder, and the per-frame cost derives from totalDecodeTime / framesDecoded. Decode fallbacks are more common on low-end Android and on iOS when the negotiated profile misses the block, which is the failure mode addressed in Forcing H.264 Hardware Acceleration on Safari.

Related: return to VP8 vs H.264 vs AV1 Codec Selection, and pair this with Dynamically Switching Video Codecs for the recovery path and Interpreting getStats() for Congestion Signals for separating encoder pressure from network pressure.