Skip to content
Performance8 min read

Four implementations of one scene: WebGL that survives a weak phone

The render tier is chosen from measured capability, not from a User-Agent string. The order of the checks is a contract, degradation only ever goes downward, and the poster frame ships in every server response.

A heavy hero scene is usually one shader, one canvas and one isMobile boolean. On a flagship it looks great. On a two-hundred-dollar Android it is a slideshow, and the first attempt to fix that almost always ends in parsing the User-Agent string.

User-Agent parsing does not work, and not because it is unfashionable. It answers a different question. An iPad reports desktop Safari. Windows tablets look like laptops, and so does an all-in-one with a touchscreen. A 2026 flagship phone outruns a 2015 laptop, and both report exactly what their vendor intends. UA Client Hints change nothing here: they report the same fact more politely, when the fact you need is how many frames this machine will actually draw.

Four implementations, not four quality settings

In lib/tier.ts the scene exists in four versions, and they are separate implementations rather than positions on a slider:

T0 static   — no canvas at all. Poster + CSS. Reduced motion, or no WebGL2.
T1 light    — 2D canvas / CSS motion. Save-Data, 2g, low-memory devices.
T2 standard — WebGL2, no postprocessing. The mobile and mid-range default.
T3 full     — WebGL2 + postprocessing. Desktop only, 8+ logical cores.

The distinction matters beyond vocabulary. A quality slider assumes the cheap version is the expensive one with options switched off — which means the expensive code is downloaded regardless. T1 here cannot do anything T2 does, and in exchange it does not drag three.js onto a metered connection.

The order of the checks is the contract

The whole detection function is six conditions, and the ordering is the interesting part:

if (prefersReducedMotion()) return TIER.STATIC;
if (conn?.saveData === true) return TIER.LIGHT;
if (!hasWebGL2()) return TIER.LIGHT;
if ((cores > 0 && cores <= 4) || (memory > 0 && memory <= 4)) return TIER.LIGHT;
if (isDesktop() && cores >= 8) return TIER.FULL;
return TIER.STANDARD;

The rule: an accessibility preference outranks a network hint, which outranks a capability probe, which outranks a performance heuristic.

prefers-reduced-motion comes first because it is not a performance signal at all. It is a medical one — vestibular disorders, photosensitivity. How fast the machine is has nothing to do with it, and if you swap the first two conditions a fast desktop with reduced motion enabled sails through to T3 with full-screen bloom. Nobody will notice that bug except the person it makes ill.

saveData and effectiveType come next for a related reason: the visitor has already told you bandwidth costs them money. Arguing with that by counting cores misses the point, because a powerful phone can be roaming.

A WebGL2 probe worth stealing

const gl = canvas.getContext("webgl2", { failIfMajorPerformanceCaveat: true });
if (!gl) return false;
gl.getExtension("WEBGL_lose_context")?.loseContext();
return true;

Three separate decisions in four lines. First, failIfMajorPerformanceCaveat: without it the browser hands you a context backed by a software rasteriser, the probe answers yes, and you have shipped T2 onto a CPU. That is the worst available outcome, because everything technically works while every frame is drawn the slow way.

Second, the context is released immediately via WEBGL_lose_context. Browsers cap the number of live WebGL contexts, and a probe that holds one until garbage collection is competing for a slot with the renderer it was run on behalf of. The release has to be deterministic, not eventual.

Third, the whole probe sits in a try/catch, because detectTier() is not allowed to throw. A device that reports nothing unusual and a device that throws on getContext both need to land somewhere sensible.

A missing value is not a low value

The most common bug in hardware heuristics is treating undefined as zero and then comparing it. navigator.deviceMemory is Chromium-only, quantised to powers of two, and capped at the top end. hardwareConcurrency is more widely available but still not universal.

So the condition is not memory <= 4 but memory > 0 && memory <= 4. Drop the first half and every Safari visitor, where the property does not exist, reads as a four-gigabyte machine and gets demoted to T1 — all of iOS and all of macOS served a reduced scene because of an absent field. "No data" has to mean "do not penalise", and it has to be written out explicitly, because the ?? 0 a line earlier looks entirely harmless.

The T3 gate is desktop plus eight logical cores. Postprocessing is not marginally more expensive: it is a full-screen multi-pass effect where every pass reads and writes a screen-sized buffer. On a mobile GPU with shared memory that is a different order of cost, not the next notch of quality.

Why the poster frame ships in every server response

SSR_TIER is TIER.STATIC, and the reason is monotonicity rather than caution. The tier is a client-side fact; on the server it can only be guessed.

Guess upward and you emit a canvas that then has to be torn down: layout has already shifted, the chunk has already been fetched, and the visitor watches the page rebuild itself. Guess downward and you emit the poster — a finished, correct frame — over which a canvas mounts if and when it is appropriate. The second move is reversible and invisible; the first is neither. Hence the rule in the file: never speculate upward.

The side benefit is that the hero is never blank while JavaScript boots. T0 is not a failure mode; it is the first frame of every visit.

Nothing runs during render

The previous implementation set the tier inside a useEffect behind a 500 ms timer. That bought two defects for the price of one: a visible pop half a second after load, and a race in which the canvas mounted before isMobile was known.

The React Compiler rules — react-hooks/purity, react-hooks/set-state-in-effect, react-hooks/immutability — were disabled in the old ESLint config. They are on now, for exactly this class of bug. The linter is cheaper than code review here because it is not enforcing taste; it is catching a specific defect that already reached production once.

Capability is not performance

The probe establishes that a device can create a WebGL2 context. It says nothing about whether the device will hold frame rate: thermal throttling three minutes in, a second tab playing video, a driver quietly falling back to software. So a measured frame rate can demote the tier after startup.

Demotion is deliberately one-way:

export function demote(tier: Tier): Tier {
  if (tier <= TIER.LIGHT) return TIER.LIGHT;
  return (tier - 1) as Tier;
}

Two decisions in four lines. The floor is LIGHT, not STATIC, because STATIC is a preference and not a performance state. Demote into it from a metric and you have silently removed motion nobody asked to lose — and you have also destroyed the distinction between "this person requested stillness" and "this phone could not keep up", which everything downstream then cannot tell apart.

The second decision is that there is no inverse function. Promoting on an improved metric is tempting and produces oscillation: the scene flips between implementations twice a minute, recreating a GL context each time. A slightly-too-low tier for the rest of the session beats a correct tier that flickers.

What this article does not contain

Numbers. No "40% faster", no per-handset results: this studio has no device lab, and a synthetic benchmark run on my own laptop measures my own laptop. The frame-rate thresholds and the length of the averaging window belong to the component that owns the render loop, and they get tuned so that a single garbage-collection pause does not count as a failure.

The checkable part is the other half, and it is the more useful one: the ordering of the conditions, the deterministic context release, the handling of an absent hint, and one-way degradation. All of it reads in one sitting — lib/tier.ts is about a hundred and thirty lines.