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.
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.
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
- Establish a baseline. Open a one-to-one call at 1280×720, run
sampleEncoder()for ten ticks, and record the medianmsPerFrameand the raw string. On a machine with a working accelerator you should see single-digit milliseconds and an accelerator family name. - 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. - Force a geometry rejection. Set
scaleResolutionDownBy: 1.5on 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. - 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.
- Confirm in the dump. Cross-check the transition timestamp in
chrome://webrtc-internals/, whereencoderImplementationappears 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.
Common Implementation Mistakes
- Testing
encoderImplementation === 'ExternalEncoder'. Exact equality fails on every simulcast adapter string and on every platform that reports a vendor-specific name. Match against a family pattern and unwrap the parenthesised list first. - Trusting
encodingInfo()for the whole call.powerEfficient: truedescribes an idle machine. Re-checkpowerEfficientEncoderafter the first 30 encoded frames and again after every resolution change, since the factory rebuilds the encoder each time. - Reacting to one bad poll. Encode time spikes on keyframes, on tab focus changes, and during the first second after a
replaceTrack(). Require two consecutive degraded polls to enter suspicion and five to act, or you will thrash the call every time the user drags the window. - Dividing by a stale frame count. A suspended simulcast layer freezes
framesEncoded, so the naivetotalEncodeTime / framesEncodedratio silently reports the last known good value forever. Always work from deltas and skip zero-delta ticks. - Treating software encode as a failure to be alarmed on. A 360p single-layer VP8 software encode is entirely healthy. Scale the threshold to the pixel rate you are asking for, and alert only when the software path cannot meet the configured framerate.
- Assuming the codec you negotiated is the one with hardware. AV1 encode is software on the overwhelming majority of client devices today, which is exactly why Configuring AV1 SVC Layers in WebRTC treats CPU as the binding constraint.
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.