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.

Why the quantiser destroys glyph stems before it touches faces A vertical one pixel glyph stem in an eight by eight block produces coefficient energy across the entire top row of horizontal frequencies. At quantiser 22 the high frequency coefficients survive and the stem reconstructs sharply. At quantiser 40 the right hand coefficients are zeroed and the stem reconstructs as a three pixel grey ramp with much lower edge contrast. One glyph stem, from source pixels to reconstruction 1. source luma block 1-px step edge, full contrast, no noise 2. transform coefficients boxed: zeroed at QP 40 kept at QP 22 energy fills the whole row 3. reconstruction QP 22 β€” hint "text" QP 40 β€” camera default stem width 2 px β†’ 4 px edge contrast 8.4:1 β†’ 1.9:1 the deblocking filter then softens what is left A face degrades continuously with QP. A glyph degrades as a cliff: the coefficients that separate one stem from the next are the first ones the quantiser spends.
Text lives in the high-frequency coefficients that a camera-tuned quantiser is designed to discard.

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.

Which knob binds at which layer of the screen encode path Six stacked layers from capture surface through content hint, codec mode, rate controller, degradation policy and encoded frame. Each layer names the API call that controls it on the right and the failure that occurs when it is left unset on the left. left: what breaks if unset β€” right: the call that binds it encode path capture surface 1920Γ—1080 picker downscales a 4K monitor to 1080p before you see it getDisplayMedia({ video: {…} }) track hint encoder guesses "motion" from a high-frame-rate source track.contentHint = 'text' codec mode select no screen-content tools, short keyframe interval (implied by the hint) rate controller budget 30 fps of repaints splits the budget into unreadable slices encodings[0].maxFramerate degradation policy first congestion event halves the frame width degradationPreference encoded frame β€” QP 22–30 unset anywhere above β†’ QP 40+ qpSum / framesEncoded
Six layers, four of them under your control; skipping any one of them puts the quantiser back in charge.

Reproduction Steps & Debugging Log Patterns

  1. 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. framesEncoded should barely move and sample() should return null most ticks β€” that is correct, not a bug.
  2. 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.
  3. Repeat the scroll with contentHint deleted and degradationPreference left at 'balanced', on the same network, and record the same counters.
  4. Diff frameWidth, average QP and qualityLimitationReason across the two runs. If frameWidth differs, 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.

Before and after: the same three lines of code under two encoder configurations The left panel shows glyph strokes smeared and merged under the default motion tuning at 960 pixels wide and quantiser 41. The right panel shows the same strokes crisp and separated at 1920 pixels wide and quantiser 28. Counters below each panel give frame width, average quantiser, frame rate and steady state bitrate. before β€” no hint, balanced degradation after β€” "text" + maintain-resolution strokes merge; 2-px gaps gone stems separated; syntax colour intact frameWidth 960 (downscaled 2Γ—) average QP 41 frames/s 27 steady state ~900 kbps bits spent on repaints nobody reads frameWidth 1920 (1:1) average QP 28 frames/s 8 steady state ~480 kbps fewer, far better frames β€” and cheaper
The tuned run is sharper, 1:1, and uses roughly half the uplink β€” smoothness was the only thing traded away.

Common Implementation Mistakes

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.