Your scene runs at 60fps on your machine, but PageSpeed gives it a 48 and blames Total Blocking Time, usually pointing at gsap.min.js. I burned a full day on this once chasing the wrong thing, so here is the diagnosis, with the parts that actually matter.
PageSpeed has no GPU
Lighthouse and PageSpeed run headless Chrome with software rendering (SwiftShader on the CPU). Your scene that costs a fraction of a millisecond on your card runs on the CPU on Google’s machine. One check tells you which world you are being scored in:
const gl = renderer.getContext();
const ext = gl.getExtension('WEBGL_debug_renderer_info');
const name = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : '';
const software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(name);
If software is true, that is the renderer PageSpeed uses. Real users on VMs and weak hardware get the same, so branch on this, not on the User-Agent.
It is not JavaScript
In the main-thread breakdown, “Other” is often bigger than “Script Evaluation”. Other is GPU waits and pixel scans, which a script profile never shows. And the long task labelled gsap.min.js is usually not GSAP: every requestAnimationFrame call in a frame lands in one task that takes the label of whoever started it, and the GSAP ticker registers first. Optimizing GSAP here is aiming at the wrong target.
The first frame compiles every program at once
The scene draws one frame on start and waits for input. Google never moves the mouse, so it only sees that first frame. That frame compiles every shader program, and after each compile the library queries the driver (getProgramParameter, uniform locations). Each query waits until the GPU has worked through its whole command queue.
On one production build (particle text + EffectComposer + UnrealBloomPass, 14 programs) that was a single 656 ms task and a score of 73, with TBT around 700 ms. None of it was JS in the sense most guides mean.
Cutting particles is the wrong move
Two reasons. First, particles are cheap (one vertex each), fill is expensive (bloom, transparency, overdraw). You cut the cheap resource and leave the expensive one. Second, a particle image has a resolution equal to the count, so halving particles on mobile just makes the text blurry while the real cost stays. I raised a phone build from 7k to 72k particles and the text got sharp with no score regression, because the count was never the problem.
Measure so you are not chasing noise
- DevTools profiling on your own card lies: the second load has the shader programs cached on disk and the compilation disappears. Use a fresh profile with
--disable-gpu. - PSI noise is 7 to 10 points. Take the median of three runs and treat anything under a 7-point change as chance.
- Read the long-tasks list with start times, not the category breakdown.
The fix, at a high level
Keep the first frame free of compilation:
renderer.compileAsync(scene, camera)in the exact render conditions of use. The colour space of the target is part of the program key, so a material compiled for the screen that later draws to the composer buffer recompiles in the first frame. That trap eats most of the gain people expect fromcompileAsync.compileAsyncwithoutKHR_parallel_shader_compilereturns immediately (the library fakes readiness), so you still need a fence. AfterPromise.all, agl.fenceSync+ non-blockingclientWaitSyncloop tells you the driver actually finished linking.- Warm up each program with one draw to a 1x1 target, one per frame, with a fence between. First use also queries uniform locations and builds the pipeline under software rendering, so it should happen here, not in the first real frame. Warm the scene with a real
render(scene)because Points is a different pipeline than triangles. - Nothing heavy in one task at
load: hand the WebGL context from your probe to the renderer, split library, renderer and scene build with a yield, and chunk pixel loops by measured cost.
On the same build this went 73 to 97 on desktop, TBT 700 to 140 ms, first frame 656 ms to a few ms, with the scene visually untouched.
I packaged the whole thing with copy-ready code, the OutputPass define fix, the mobile scaling table and a PageSpeed measurement script as a paid skill for coding agents like Claude Code and Codex: Webgl Pagespeed Rescue - Claude Skill | MCP Market . Everything above is the actual gist, free.
Curious whether others here have hit the same “Other is huge but my JS profile is clean” wall, and how you approached it.