Return to notes
Engineering notes· 5 min read

A background that cost more than the page it sat behind

My portfolio ran a neural network in the fragment shader to draw a dark gradient. Replacing it with four CSS gradients removed a dependency, a context leak, and every frame of main-thread work.

#WebGL#CSS#Performance#React

The ambient wash behind this portfolio was a WebGL component called DarkVeil. It looked correct, so for months I did not question it. Then I opened a performance profile for an unrelated reason and found the page burning frames while completely idle.

The background was the most expensive thing on the site, and it was decoration.

What the shader was actually doing

DarkVeil drew a full-screen triangle and coloured it with a CPPN — a compositional pattern-producing network. That is a small neural network evaluated as a function of pixel coordinates. The fragment shader contained its weights inline: eight 4x4 matrix layers with sigmoid activations between them, hardcoded as literals.

Every pixel ran the whole network. Every frame.

per fragment:
  build a 2-value input from (x, y)
  8 mat4 multiplies + adds
  4 sigmoid activations  (each an exp() over 4 components)
  RGB->YIQ hue rotation, scanline, noise

At 1920x1080 on a 2x display that is roughly 8.3 million fragments per frame. At 60fps it is around 500 million shader invocations per second, each doing dozens of matrix operations, to produce a slowly-moving purple blur that sits at 65% opacity behind text.

The renderer was also set to Math.min(window.devicePixelRatio, 2), so the cost doubled on exactly the laptops most likely to be running on battery.

The bug underneath the cost

The expensive part was a design choice. The next part was a defect.

useEffect(() => {
  const renderer = new Renderer({ dpr, canvas });
  // ... build program, start rAF loop
  return () => {
    cancelAnimationFrame(frame);
    window.removeEventListener('resize', resize);
  };
}, [hueShift, noiseIntensity, speed, /* ... */, lightMode]);

Every prop in that dependency array rebuilt the entire renderer. On the projects page, hueShift is derived from the selected project's accent colour, so clicking a project constructed a new WebGL context.

The cleanup cancelled the animation frame and dropped the resize listener. It never disposed the context. Browsers cap live WebGL contexts at around sixteen; past that they start evicting the oldest ones, which is why a background occasionally vanished after browsing enough projects. I had noticed that behaviour before and filed it mentally as "flaky GPU thing".

It was a dependency array.

Four gradients do the same job

The output was a soft, slowly-drifting colour field. Nothing about that requires a shader. It requires shapes with soft edges that move slowly.

.aurora-veil-layer-a {
  top: -30%;
  left: -20%;
  width: 88%;
  height: 92%;
  background: radial-gradient(
    closest-side,
    hsl(calc(276 + var(--veil-hue)) var(--veil-sat) var(--veil-light) / 68%),
    hsl(calc(276 + var(--veil-hue)) var(--veil-sat) var(--veil-light) / 0%)
  );
  animation: aurora-drift-a var(--veil-cycle) ease-in-out infinite alternate;
}

Four of those, on cycle lengths multiplied by 1.37, 0.83, and 1.61 so the composite never visibly repeats. Every keyframe touches transform only, which means the compositor owns the animation and the main thread does nothing after first paint.

Two details mattered more than I expected:

No blur filter. The obvious way to make soft blobs is filter: blur(120px) on a solid shape. But a blurred layer has to be re-rasterized whenever its contents change, and a large blur radius over a full-viewport element is genuinely expensive. A radial gradient with an alpha falloff is already soft, and it costs one gradient rasterization that the browser then caches.

Fade to transparent in the same hue. Ending a gradient on the transparent keyword is ending it on transparent black, and some compositing paths let that grey out the midpoint. Ending on hsl(... / 0%) of the same colour keeps the ramp clean.

The theme handling got simpler by deletion

The old component called a useTheme hook to decide between a light and dark uniform. That hook runs a MutationObserver on the document element. So each veil instance observed the DOM, re-rendered on theme change, and rebuilt the GL pipeline because lightMode was in the dependency array.

The replacement is a CSS rule:

.light .aurora-veil {
  --veil-sat: 54%;
  --veil-light: 70%;
  --veil-base-light: 90%;
}

No observer, no subscription, no re-render. The browser already knows what class is on <html>.

This is the part I keep relearning: when a visual concern is expressed in JavaScript, it acquires a lifecycle. Expressed in CSS, it does not have one.

Scroll had two separate problems

With the background fixed, the hand-off from the hero section still felt abrupt. There were two causes, and only one of them was the background.

The first was mine. I had moved scroll-linked decoration off React state and into direct DOM writes, which was right, but I wrote the transforms and then read offsetHeight to decide whether to show the back-to-top button:

veilRef.current.style.transform = `translateY(${offset}vh)`;
// ...
const landingHeight = landingRef.current?.offsetHeight; // forced layout

Reading a geometry property after a style write forces the browser to recalculate layout synchronously, before it can answer. Doing that inside every scroll frame is the classic layout thrash. The fix is ordering, not cleverness: take every measurement first, then write.

The second was the parallax curve. The veil rose at 40% of scroll rate and its progress was clamped at one viewport:

const progress = Math.min(Math.max(scrollY / innerHeight, 0), 1);
const offset = -40 * progress;

At the moment you clear the hero, that value stops changing. The background goes from moving at 40% of scroll speed to zero instantly. The position is continuous but the velocity is not, and velocity discontinuity is exactly what "snappy" feels like.

Easing the curve so it decays to a stop removes the jump:

const offset = -TRAVEL * (1 - (1 - progress) ** 2);

The derivative of that curve at progress = 0 is 2, so with TRAVEL set to half the old distance it opens at the same rate and then settles. Same feel entering the hero, no jolt leaving it.

Animate position continuously and you get a shape. Animate velocity continuously and you get a feel.

What I would check earlier next time

The honest lesson is not "WebGL is heavy". WebGL is fine. The lesson is that I never asked what the background cost, because it was working and it was pretty.

Three questions I now apply to anything decorative:

QuestionWhy it catches things
What does this cost when the user is doing nothing?Idle cost is pure waste and never shows up in interaction testing
Does it hold a resource with a hard limit?GL contexts, observers, audio nodes, and sockets all have ceilings
Could CSS express this?If yes, it has no lifecycle to leak

The replacement removed the ogl dependency, a per-frame render loop, a MutationObserver per instance, and a WebGL context leak. It also renders identically on a phone that would previously have thermal-throttled.

The page looks the same. It just stopped charging for it.

Continue reading

What a telemetry dashboard taught me about resilient interfaces