Keeping Shared Text Readable with contentHint
A shared terminal is either readable or it is worthless; there is no graceful middle. This guide is part of the Screen Sharing & Content Hints guide, and it answers one narrow question: given a surface whose value is entirely in its glyphs β an IDE, a spreadsheet, a log tail, a SQL console β which contentHint value to set, what to pair it with so the hint survives contact with the rate controller, and how to prove with a number rather than a squint that the receiver can read it.
Context & Trade-offs
The default encoder configuration in every browser is tuned for a camera, and a camera signal has a useful property that screen content does not: it is soft. Lens blur, sensor noise and 30 fps temporal dither mean the high-frequency end of each transform block carries little information, so a quantiser can throw it away and nobody notices. Text is the opposite signal. An anti-aliased 12 px glyph stem is a one- or two-pixel luminance step, and a step function has energy in every frequency band of the block that contains it. Those are exactly the coefficients the quantiser zeroes first as the quantisation parameter rises.
That is why the failure is so abrupt. Between an average QP of 28 and an average QP of 42 a face loses a little skin texture, while a monospace stem loses the coefficients that distinguish it from its neighbour and the reconstructed block becomes a three-pixel grey ramp. On the VP8/VP9 scale (0β63, which is what Chromium reports for those codecs; H.264 uses 0β51 and needs its own thresholds) the practical bands for 12 px text at 1:1 are: under 32 reads cleanly, 32β40 is legible but tiring over a 40-minute call, and above 42 stems merge and the viewer starts asking people to read the screen aloud.
Between "text" and "detail" the practical difference is smaller than the names suggest. In current Chromium both values route the track into the same screen-content encoder mode β longer keyframe intervals, a quantiser floor biased low, motion search relaxed β so switching between them rarely moves the numbers. The reason to prefer "text" for glyph-dominated surfaces anyway is that it is the value a codec with screen-content coding tools is entitled to act on: VP9 and AV1 can enable palette-style and intra-block-copy paths that reproduce repeated glyph shapes almost exactly, while an H.264 encoder shipped in a browser has no equivalent, which is one more reason the choice made in VP8 vs H.264 vs AV1 Codec Selection matters more for shares than for camera calls. Use "detail" when the surface is mixed β a design tool, a dashboard, a map β where you want spatial fidelity but the content is not overwhelmingly type.
The arithmetic behind that cliff is worth doing once, because it explains every tuning decision that follows. A 1920Γ1080 surface is 2.07 million luma pixels. A full-page scroll invalidates essentially all of them, so during a scroll the encoder is coding a new picture every frame with almost no useful prediction. At a realistic 800 kbps steady-state for a document share, running at 30 fps leaves about 27 kbit β 3.3 kB β per frame, or 0.013 bits per pixel; at 8 fps the same budget gives 100 kbit per frame, or 0.048 bits per pixel. Nothing else in the API changes the per-pixel budget by 3.7Γ for free. That single ratio is the difference between a quantiser sitting at 40 and one sitting at 28, and it is why frame rate is the first thing to spend and the last thing to defend on a text surface.
Two source-side effects finish the job that the quantiser starts. The first is subpixel anti-aliasing: on Windows and on some Linux desktops, glyph edges are rendered with per-channel offsets, so what looks like black-on-white to the eye is actually a fringe of coloured pixels. The second is 4:2:0 chroma subsampling, which halves colour resolution in both axes before the encoder sees the frame, turning those fringes into a muddy halo that costs bits and buys nothing. Neither is fixable from the WebRTC API, but both are fixable by the presenter: grayscale anti-aliasing, a heavier font weight, and browser or editor zoom at 125β150% so a 12 px glyph occupies 15β18 device pixels. Zoom is by far the highest-leverage change available and it costs zero bitrate, because the capturer is change-driven and a larger font does not repaint more often.
Minimal Runnable Implementation
Setting the hint is one line. The part worth writing carefully is the closed loop that verifies the hint achieved anything, because contentHint is advisory: nothing in the API guarantees the encoder honoured it, and nothing tells you when the bandwidth estimate has fallen far enough that even maintain-resolution cannot hold QP down. The controller below polls at 1 s intervals, derives average QP from qpSum deltas, and trades frame rate β never resolution β until QP comes back under target.
// Hold encoder QP under a legibility target by spending frame rate, never pixels.
class TextLegibilityController {
constructor(sender, { targetQp = 32, minFps = 2, maxFps = 12 } = {}) {
this.sender = sender;
this.targetQp = targetQp; // VP8/VP9 scale; use ~26 for H.264's 0-51 range
this.minFps = minFps;
this.maxFps = maxFps;
this.fps = maxFps;
this.prev = null; // last {qpSum, framesEncoded} sample
}
async apply(track) {
track.contentHint = 'text'; // advisory: biases the encoder to screen-content mode
const params = this.sender.getParameters();
if (!params.encodings?.length) params.encodings = [{}]; // Firefox may return empty
params.degradationPreference = 'maintain-resolution'; // the hint alone does not pin this
params.encodings[0].scaleResolutionDownBy = 1; // 1:1 pixels or the glyphs are gone
params.encodings[0].maxFramerate = this.fps;
await this.sender.setParameters(params);
}
// Call once per second. Returns the average QP observed over the interval, or null.
async sample() {
const report = await this.sender.getStats();
let out = null;
report.forEach((s) => { if (s.type === 'outbound-rtp' && s.kind === 'video') out = s; });
if (!out || out.qpSum === undefined) return null; // Safari omits qpSum entirely
const prev = this.prev;
this.prev = { qpSum: out.qpSum, framesEncoded: out.framesEncoded };
if (!prev) return null; // need two samples for a delta
const frames = out.framesEncoded - prev.framesEncoded;
if (frames < 1) return null; // static surface: nothing encoded
const avgQp = (out.qpSum - prev.qpSum) / frames;
await this.retune(avgQp, out.qualityLimitationReason);
return avgQp;
}
async retune(avgQp, reason) {
let next = this.fps;
// Too soft and it is bandwidth, not CPU: buy quality with frames.
if (avgQp > this.targetQp + 4 && reason !== 'cpu') next = Math.max(this.minFps, this.fps - 2);
// Comfortably sharp and unconstrained: give smoothness back, one step at a time.
else if (avgQp < this.targetQp - 6 && reason === 'none') next = Math.min(this.maxFps, this.fps + 1);
if (next === this.fps) return; // setParameters is a control-plane call
this.fps = next;
const params = this.sender.getParameters();
params.encodings[0].maxFramerate = next;
await this.sender.setParameters(params);
}
}
Three details in that controller are deliberate. The thresholds are asymmetric β drop two frames per second when QP exceeds target by 4, but add back only one when it sits 6 below β because oscillating between 6 and 12 fps is far more visible to a viewer than sitting one step too conservative. The reason !== 'cpu' guard exists because a CPU-limited encoder is already dropping frames on its own; cutting maxFramerate further at that point just deepens the stutter without touching QP. And sample() returns early when framesEncoded has not advanced, which on a genuinely static surface is most ticks: dividing a qpSum delta of zero by a frame delta of zero produces a NaN that will happily poison the controller into pinning frame rate at the minimum for the rest of the call.
The reason this loop moves maxFramerate and never scaleResolutionDownBy is the whole thesis of the page: at a fixed bitrate, halving the frame rate roughly doubles the bits available to each frame that is sent, which lowers QP directly; halving the resolution lowers QP too, but it does so by destroying the glyph geometry first. The general form of that argument, applied to camera sources as well, is laid out in degradationPreference: Resolution vs Framerate.
Reproduction Steps & Debugging Log Patterns
- Share a maximised editor at 1080p with a 12 px monospace font and roughly 40 populated rows, then start
sample()on a 1 s interval and let the surface sit completely still for 10 s.framesEncodedshould barely move andsample()should returnnullmost ticks β that is correct, not a bug. - Scroll the document continuously for 5 s. This is the measurement window: it forces dense inter-coded frames and pushes QP to its worst sustained value.
- Repeat the scroll with
contentHintdeleted anddegradationPreferenceleft at'balanced', on the same network, and record the same counters. - Diff
frameWidth, average QP andqualityLimitationReasonacross the two runs. IfframeWidthdiffers, the comparison is already decided β the baseline run downscaled and no QP figure will rescue it.
// Two 5 s scroll windows on the same 1.4 Mbps uplink, one sample per second.
// baseline: no hint, degradationPreference 'balanced'
// t+1 frameWidth=1920 avgQp=34.2 fps=24 reason=none
// t+2 frameWidth=1280 avgQp=31.8 fps=25 reason=bandwidth <- downscaled, QP "improved"
// t+3 frameWidth=1280 avgQp=38.6 fps=26 reason=bandwidth
// t+4 frameWidth= 960 avgQp=41.1 fps=27 reason=bandwidth <- glyphs now sub-pixel
// tuned: contentHint='text', maintain-resolution, maxFramerate 12
// t+1 frameWidth=1920 avgQp=29.4 fps=11 reason=none
// t+2 frameWidth=1920 avgQp=33.1 fps= 9 reason=bandwidth <- controller drops to 8 fps
// t+3 frameWidth=1920 avgQp=28.7 fps= 8 reason=none
// t+4 frameWidth=1920 avgQp=27.9 fps= 8 reason=none <- stable and readable
The trap in that log is line t+2 of the baseline run: average QP went down while the picture got worse, because the encoder had fewer pixels to spend bits on. QP is only comparable between runs at identical frameWidth, which is precisely why pinning resolution is a prerequisite for measuring anything at all. Read qualityLimitationReason alongside it every tick, using the same discipline as Interpreting getStats() for Congestion Signals β a cpu reason means dropping frame rate will help and dropping bitrate will not.
For a ground-truth check that does not depend on anyoneβs eyes, add a fifth step: on the receiving client, draw the decoded <video> element into a canvas at native size once per second and sample a horizontal run of pixels across a known glyph stem β a vertical bar drawn in a fixed corner of the shared surface works as a calibration target. Michelson contrast across that run, (max β min) / (max + min), collapses from around 0.79 on a clean encode to below 0.35 once the stem has smeared, and unlike QP it is directly comparable across codecs, across browsers, and across resolutions. Log it next to the senderβs average QP and the two curves will track each other closely enough that you can stop capturing screenshots for bug reports.
Common Implementation Mistakes
- Setting
contentHinton the stream instead of the track. The property lives onMediaStreamTrack. Assigning it to aMediaStreamsilently creates an expando and nothing changes. Fix: set it on the video track returned bygetVideoTracks()[0]. - Setting the hint after the encoder is already running and expecting an instant change. Some Chromium versions only re-read the mode when the encoding session is re-initialised. Fix: set the hint before
addTrack/replaceTrack, and if you must change it live, follow with asetParameters()call to force a reconfiguration. - Comparing QP across runs with different
frameWidth. A downscaled encode reports a flatteringly low QP while being visibly worse. Fix: gate the comparison on identical resolution, or compare bits per source pixel instead. - Trusting
qpSumon every platform. Safari does not populate it, and some hardware encoders report a vendor-specific scale rather than the codecβs. Fix: feature-detect and fall back toframesEncodedplusbytesSentper changed region β and check whether you are even on a software encoder, using the technique in Detecting Hardware vs Software Encoding. - Leaving
maxFramerateat 30 βjust in case someone plays a videoβ. That single decision costs roughly 3β4Γ the per-frame bit budget and is the most common reason a correctly-hinted share is still soft. Fix: default to 8 fps and raise it only on an explicit user action.
FAQ
Should I use "text" or "detail" for a spreadsheet?
Use "text". A spreadsheet is grid lines and small digits, which is the same high-frequency signal as source code. Reserve "detail" for surfaces where images and type share the frame, such as a design canvas or a map.
Does a lower frame rate actually improve sharpness, or does it just look that way?
It measurably improves it. At a fixed bitrate the encoder divides the budget among the frames it sends, so going from 24 fps to 8 fps triples the bits available per encoded frame, which shows up directly as a 6β12 point drop in average QP on a scrolling document.
Why does my share look perfect locally but soft on the receiver?
The local preview element renders the capture track, not the decoded stream β it never passes through the encoder at all. Always judge legibility from a second clientβs decoded video, or from the receiverβs inbound-rtp stats.
Related: return to Screen Sharing & Content Hints, and pair this with Sharing a Tab vs a Window vs the Whole Screen and Capturing System Audio with getDisplayMedia.