This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Existing codebase: primaworkflows.com, one 321,920-byte HTML
file, SHA-256 06a768bbโฆ. All index.html line numbers below are against that published
file. prima-visual.js line numbers are against the file the live site serves today
(8,773 bytes, SHA-256 dc78c46dโฆ). Every number is measured, and the places where I got
one wrong are left in. Production has not been swapped for the after. A same-session
A-vs-C table is still empty on purpose.
Project Overview
My site lags on my phone. It is the only page I own that strangers and clients actually load,
and it feels slow in the hand. That was the bug. I had the fix written before I opened the file:
five stacked canvases, a pile of animation loops, collapse them into one clock and move on.
Then I read the file instead of my notes about the file.
// index.html:3824
const WORLD_CANVAS_ENABLED = false;
Two of the four persistent animation loops I was about to collapse never start. The flag gates
the call that kicks them off, at :7103.
It gates only that. I want to be exact, because my first draft of this paragraph said the
flag also gated their 2D contexts, and then I checked the published file instead of my working
copy:
// index.html:3832, :3834, :3875 โ no guard on any of them
const ctx = canvas.getContext('2d');
const vctx = visitorCanvas.getContext('2d');
const visitorPreviewCtx = visitorPreview.getContext('2d');
So production still creates three 2D contexts for canvases nothing
ever draws to. The loops are dead; the memory is not. Guarding those three lines is a separate
one-line-each change that is in my working tree and has not shipped, and I am not counting it
in anything below.
A third loop, the custom cursor, exits immediately on touch devices:
// index.html:6656
(function primaCursor() {
if (window.matchMedia('(hover: none)').matches) return;
Mobile is the measurement that matters here โ it is a phone-facing page. So on the device I
was optimizing for, the page had one persistent JS loop, not four. The optimization I had
specified had almost nothing to collapse. If I had built it and measured, I would have seen
nothing move and had no way to tell a failed fix from a fix with no room to work.
I had written the plan from a document I wrote myself, about a file I wrote myself.
Bug Fix or Performance Improvement
The actual bug
Once I was reading rather than remembering, I found this.
// index.html:3188
function canRunWebGLHero() {
return window.matchMedia('(hover: hover)').matches; // โ returns here. always.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false;
if (window.matchMedia('(max-width: 820px)').matches) return false;
if ((navigator.hardwareConcurrency || 4) < 4) return false;
if (navigator.deviceMemory && navigator.deviceMemory < 4) return false;
try {
const c = document.createElement('canvas');
return !!(c.getContext('webgl2') || c.getContext('webgl'));
} catch (error) {
return false;
}
}
Five guards under an unconditional return. Not one of them executes in the published build I
audited, and that build is the one live right now. I have not audited every prior deployment,
so I am not claiming a start date for it. The patch is deleting that one
return. I am not writing the fix in the past tense until the line is gone from the file a
visitor loads.
What that silently switched off:
| Guard | Intended | Actual, today |
|---|---|---|
prefers-reduced-motion |
no WebGL hero | ignored |
max-width: 820px |
skip small screens | ignored |
hardwareConcurrency < 4 |
skip weak CPUs | ignored |
deviceMemory < 4 |
skip low-RAM machines | ignored |
webgl2 / webgl probe |
skip unsupported devices | ignored |
The reduced-motion one is the part I am least comfortable with. Someone sets that preference at
the operating system level, for reasons that are theirs, and my page has been overriding it since
the day I shipped it. Not because I disagreed with them. Because a return was on the wrong
line.
The capability probe is the expensive one. A hover-capable machine with no WebGL support
downloads and compiles Three.js, tries to start, throws, and the failure lands in a
.catch(err => console.warn(...)) where nobody sees it. It pays full price for a feature it
cannot run.
Code
Exact diffs of both changed files:
gist.github.com/keniel13-ui/3c05e11202d048ad78a11ce2de215e8d
โ two unified diffs against the deployed 06a768bb file, plus a README of what each hunk does
and what is deliberately excluded from the claims. Production has not been swapped, so the
before in that diff is what you get if you load the site right now.
Three scripts, and the one I was wrong about
The page declares three third-party scripts on every load:
<!-- index.html:129-131, verbatim from the live page. Note what the first one is missing. -->
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js" defer
onload="window.dispatchEvent(new Event('gsap-ready'))"></script>
The first tag has no defer. The two GSAP tags do. So Supabase is a plain script in
<head>: the browser stops building the DOM, fetches it, parses it, executes it, and only then
continues. Parser-blocking, in the head, for a library whose every call site is behind a flag
that is off.
I had a defer on that line in an earlier version of this post. I put it there. It is not in the
file โ I had copied the tag out of my own working branch, where a previous change had added one,
and pasted it as though it were production. If you are going to quote code, quote the deployed
bytes.
Measured from the URLs the production page resolved during this audit. GSAP is pinned to
3.12.5. @supabase/supabase-js@2 is not a pin โ jsDelivr treats @2 as a moving
major-version alias, and it resolved to 2.112.3 at the time of measurement
(x-jsd-version: 2.112.3). If you re-run this later and get a different Supabase number,
that is why:
| gzip transfer | uncompressed JS source | |
|---|---|---|
supabase-js@2 |
54,607 B | 212,199 B |
gsap.min.js |
28,200 B | 72,214 B |
ScrollTrigger.min.js |
17,681 B | 43,380 B |
| total | 100,488 B | 327,793 B |
Those are three different units and I want to keep them apart. 327,793 B is uncompressed
JavaScript source โ slightly more source than the 321,920-byte HTML document itself.
100,488 B is the measured gzip transfer at the moment of the audit. I re-fetched the same
three URLs while finishing this post and got 100,148 B โ the uncompressed source did not
move, the compressed transfer did. That is the moving @2 alias and CDN recompression showing
up in my own numbers, which is exactly why the alias is disclosed above rather than presented as
a pin. Both of these are cold-load figures; a repeat visitor
with a warm cache does not retransmit them.
The honest headline is the source figure: three libraries carrying more JavaScript source than
the entire document they were decorating.
Supabase is straightforward. Every one of its call sites โ loadWorldState,
loadVisitorAvatars, subscribeToVisitorAvatars โ is invoked only inside
if (WORLD_CANVAS_ENABLED). That flag is false. In the published build audited here
those call sites are unreachable โ I have not audited every prior deployment, so I am not
claiming they never fired in the site's history. The library was still being fetched and
parsed on every cold load regardless.
GSAP is where I was wrong, and I want it on the record because it nearly became the headline
of this post.
I grepped index.html for gsap, found nothing outside the script tags, and wrote a comment
into the working file stating the libraries had zero call sites and that the gsap-ready event
had no listener. Both sentences were false. I had grepped one file on a site with several.
// prima-visual.js (live, dc78c46dโฆ):226
const { gsap, ScrollTrigger } = window;
gsap.registerPlugin(ScrollTrigger);
gsap.to({ progress: 0 }, {
progress: 1,
ease: 'none',
scrollTrigger: { trigger: document.documentElement, start: 'top top',
end: 'bottom bottom', scrub: 1.2,
onUpdate: (self) => { scrollTarget = self.progress; } },
});
// prima-visual.js:248
window.addEventListener('gsap-ready', wireGSAP, { once: true });
Line 248 is the listener I said did not exist. And scrub: 1.2 was doing real work: it is the
weight in the scroll, the reason the background trails your finger instead of snapping to it.
Deleting the tags did not remove dead code. It removed a feature, quietly, in a way no
performance score would ever have shown me.
So the honest version of this optimization is not "I deleted libraries nothing called." It is
"I used one behaviour out of 115,594 bytes of library source, and I replaced that one
behaviour."
Reading the render loop showed the replacement was smaller than expected, because half of it
already existed:
bgUniforms.uScroll.value += (scrollTarget - bgUniforms.uScroll.value) * 0.04;
There were always two easing stages. ScrollTrigger's scrub was the first. That line was the
second, and it was mine. Removing the tags took out stage one only โ the page never went
un-eased, it just lost the lag.
// replaces gsap + ScrollTrigger. this is what is in the after file, not a cleaned-up retelling.
const SCRUB_SECONDS = 1.2; // the name is a lie. it is a time constant, not a catch-up.
let scrollRaw = 0, scrollEased = 0;
const readScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
scrollRaw = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
};
readScroll();
window.addEventListener('scroll', readScroll, { passive: true });
window.addEventListener('resize', readScroll, { passive: true });
// inside the existing rAF loop:
const dt = Math.min(0.1, (now - lastFrame) / 1000); // clamped for tab-switch gaps
scrollEased += (scrollRaw - scrollEased) * (1 - Math.exp(-dt / SCRUB_SECONDS));
GSAP's scrub: 1.2 means caught up in 1.2 seconds. Mine is an exponential time constant:
at ฯ = 1.2 it is 63% there at 1.2 s and needs about 3.6 s to reach 95%. If I define "caught
up" as ~95% settled โ and that is my definition, not one GSAP supplies โ then ฯ โ 0.4
puts the settling time near 1.2 s. That is a heuristic mapping, not an equivalence. I left the wrong number in the file, under a name that claims I matched
the library, because the only test that settles the feel is a thumb on a phone. I have not
done that pass yet. This approximates scrub. It does not reproduce it.
One property claim, narrowed after review, because my first version of this sentence was
wrong. The new first stage is time-based rather than a fixed per-frame lerp. But the
second stage โ the * 0.04 line above, which was always mine โ is still frame-based. So the
combined visual response is not refresh-rate invariant, and I am not claiming it is. Only
the stage I replaced is.
One more deletion, named so I do not take credit for it: the published page fires a WebGL intro
at load (index.html:6576, fireIntro). I had already rejected that motion as the owner. The
after-build does not call it. Clear the Lineup does not let me score a deletion as an
optimization, so it is not in any delta I will publish.
My Improvements
The standing cost nobody counted
The frozen contract I wrote counted canvases and rAF loops. It did not count CSS.
Gemini counted "15+ infinite" and I copied it into a draft before checking. Then I did the
thing this whole article is about and went and looked.
The published file has 15 infinite animation declarations: 14 written as CSS rules, one
injected from JavaScript. Of the 14 CSS rules, six target classes that appear nowhere in
the DOM and are never injected by JavaScript: .feed-pulse-dot, .feed-line, .sig-dot,
.hero-scan, .hero-console, and .scroll-cue. That last one is easy to miss because
.prima-scroll-cue is live in the hero. .scroll-cue is a different selector. There is no
id="scroll-cue" either โ a scroll handler looks for one and finds nothing. They are dead
rules. They cost nothing at runtime because there is nothing to animate.
So the real standing set is eight live CSS rules plus ambientDrift, one of which is
seedBreathe โ the Seed of Life โ which stays. Everything else is decoration running whether
or not anyone can see it.
All eight live CSS targets sit inside <main>, so main > section[id] reaches every one. I
checked that rather than assuming it, because the selector does not match pseudo-elements
either, and I had already been wrong once about what a selector covered.
That is the third time on this one page that a count of declarations got reported as a count
of things happening: four loops of which two never start, fourteen CSS infinite rules of
which six target nothing, and my own contract that warned against exactly this in writing
before doing it twice.
The page already had an IntersectionObserver, and I nearly claimed credit for it. It is not a
brake:
obs.unobserve(entry.target); // one-shot card reveal, then it lets go
It reveals cards once and unhooks, and the whole block exits early under prefers-reduced-motion.
So a second observer, doing an actual pause:
[data-ctl-offscreen], [data-ctl-offscreen] * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-play-state: paused !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
const obs = new IntersectionObserver(entries => {
entries.forEach(e => e.isIntersecting
? e.target.removeAttribute('data-ctl-offscreen')
: e.target.setAttribute('data-ctl-offscreen', ''));
}, { rootMargin: '200px 0px 200px 0px', threshold: 0 });
document.querySelectorAll('main > section[id]').forEach(s => obs.observe(s));
document.addEventListener('visibilitychange', () => { /* whole-tab pause */ });
One check before claiming that gates the standing cost broadly: [data-ctl-offscreen] * does
not match pseudo-elements, so an infinite animation on a ::before or ::after would keep
running off-screen. I went and looked โ zero of the infinite animations in this file live on
a pseudo-element, so the selector covers them. If yours do, add ::before and ::after.
The fifteenth is ambientDrift, injected from JavaScript rather than written in the
stylesheet, which is why it survives a grep of the CSS. Its elements are appended into the
sections the observer already watches, so the descendant selector catches it.
Nothing is deleted. Fifteen infinite-animation declarations before, fifteen after. Only the
play state is gated, and only where nobody can see it. The selector is main > section[id] โ eleven
sections, including #world, which is where the hero lives. Off-screen the Seed rests.
On-screen it still breathes.
Numbers
Both sides were deployed as previews in the same Vercel project, alternating A/C in one
session, lighthouse 13.4.1, mobile, default simulated throttle. That matters: Vercel injects
vercel.live/feedback.js into previews and not into production, so measuring production against
a preview compares two different pages. Both of these carry it.
What I can report
Total transfer weight is not a CPU measurement. It is a sum of response sizes, so it does not
move with host speed:
| A โ before | C โ after | |
|---|---|---|
| Total byte weight, median of 3 | 568,605 B | 467,962 B |
| Run-to-run spread | 1,616 B | 952 B |
| Resources | 10 | 10 |
Delta: 100,643 B โ 98.3 KB, 17.7% of the before.
Worst-case A still beats best-case C by 98,993 B. Every A run transferred more than every C run,
with no overlap. And it independently agrees with the figure I got by curl-ing the three CDN
URLs directly (100,148โ100,488 B) โ two different methods, same answer, which is the only reason
I trust either.
What I cannot report yet
Nothing CPU-dependent. My own gate refused all six runs.
| run | benchmarkIndex | verdict |
|---|---|---|
| A-01 / A-02 / A-03 | 833 ยท 827 ยท 783 | below floor |
| C-01 / C-02 / C-03 | 623 ยท 214 ยท 660 | below floor |
The floor is 1500. A single calibration run half an hour earlier scored 1711 โ then ten
back-to-back Lighthouse runs on an 8 GB laptop drove the host underneath its own threshold. So I
have LCP, TBT and main-thread numbers from those six runs, and by the rule I wrote before I saw
them, they do not go in this post.
I want to be exact about what that costs the submission: the headline performance claim here is
a 17.7% transfer reduction and a real bug fix, not a Lighthouse score. The CPU numbers need a
machine that is not also running the thing doing the measuring, and I do not have one today.
Every discarded run is published in the gist anyway โ measurements.md โ with its
benchmarkIndex in the same row, so you can audit the discard instead of taking my word that
one happened.
Why the before-number is not the worst one I have.
The first Lighthouse run on this page, 2026-08-17, scored 27, with LCP 10.4 s and TBT 13,310 ms.
It is the most flattering baseline available and I am not using it. Seven later runs of the
same URL, same tool version (lighthouse 13.4.1), same declared throttle, said LCP 3.67โ5.48 s
and TBT 216โ709 ms. Run one has never reproduced. Using it as the baseline would make any
before-and-after look dramatically larger than the reproducible baseline does. I am not putting
a multiple on that until the table below exists.
I also cannot compare a measurement taken on a quiet host to one taken on a dying one. Every
Lighthouse report carries environment.benchmarkIndex, the host speed it saw. One set of my
runs on 2026-08-17 sat at 302โ1113. Every other production set that day sat at 1705โ2342. No
overlap. Those slow runs were taken while the laptop was running out of memory; the session
died 27 minutes later. The tool exited cleanly and wrote valid JSON the whole time.
So, my inclusion rule โ 1500 is a threshold I chose, not a Lighthouse validity boundary:
same session, both sides, benchmarkIndex โฅ 1500 on every run, or the numbers do not go in.
What I am not claiming
- The loop collapse is not the win. On mobile there was one loop. I left it alone rather than ship a change I could not attribute.
- The 173,280 bytes of inline JavaScript inside the 321,920-byte HTML document are untouched. That is the largest remaining LCP cost and splitting it is a restructure, not an optimization. Gemini called the inline JS "168 KB." Measured off the published file it is 173,280 bytes.
- Three.js is still on the page.
prima-visual.js(8,773 bytes, live) imports it after first paint. On hover-capable desktops,hero-webgl.js(12,852 bytes, live) imports the same module. One download, two call sites. Lighthouse flags 151 KB of it as unused. Gating the visual layer on mobile would flatten the page, so it stays until I have something better than a smaller file. - The
prefers-reduced-motionguard does not work today. It will work when that earlyreturnis gone. I do not get to write that sentence in the past tense until a visitor's browser actually takes the other path. - Removing
fireIntrois owner-directed deletion. It is disclosed. It is not the optimization. - This is not live on
primaworkflows.comyet. The hashes above are the before. The after is still a local preview candidate.
The part I would tell someone starting
I wrote a careful specification of my own site, from memory of my own site, and it was wrong in
three places: two loops that never ran, a whole class of animation I had not counted, and a
library I called dead that was drawing the scroll. Every check I ran was correct. Every one of
them was narrower than the claim I made from it.
The bug in the code was five guards under a return. The bug in me was the same shape one level
up: a real result, reported as if it covered more ground than it did.
Best Use of Google AI
I gave Gemini the frozen before-state and asked it to name the contention before I implemented
anything, so the diagnosis could not be written backwards from the fix. The raw stdout is
timestamped. I did not get to edit what it said.
It got the thing I had missed: competing infinite CSS keyframe animations, which my own
contract had not counted at all because I had been counting canvases and rAF. It also separated the two
hypotheses: the inline parser-blocking JS (173,280 bytes, not the 168 KB it printed) as the
more plausible cold-load and LCP cost, and the persistent animations as candidates for the
standing main-thread cost. I have not measured that split, so those are hypotheses and not
causes, and I am not going to promote them past what the table below supports. The distinction
still changed what I measured: collapsing loops and then judging the result on LCP would have
shown a real fix as a failure.
It also told me the page had no IntersectionObserver. The published HTML has one, at line
-
hero-webgl.jshas another. I checked before believing it, which is the only reason that error is a footnote instead of a paragraph in this post.
If Gemini had added nothing I would have dropped this category rather than backfill a prompt
after the fix. It added the CSS count. That was enough to keep the section, and not enough to
let it write the article.
Top comments (0)