Originally published on grodev.pl.
Your 3D scene runs smoothly on your machine. Sixty frames, no jank. You drop the page into PageSpeed Insights and get a 48. Total Blocking Time is red, and the main-thread breakdown shows one long task blamed on gsap.min.js. The first instinct is "too much JavaScript" or "too many particles". Both are almost always wrong, and chasing them can eat a whole day.
This post is the diagnosis, not the recipe. I will show you why a heavy three.js scene scores badly in PageSpeed and where the cost really sits.
PageSpeed has no GPU
This is the key that changes everything else, so start here. Google does not measure your page on a graphics card. Lighthouse and PageSpeed run headless Chrome where WebGL goes through software rendering on the CPU (SwiftShader). Your scene, which takes a fraction of a millisecond on your GPU, runs on the CPU on Google's machine.
You can confirm it with one read of the renderer name. If WEBGL_debug_renderer_info returns swiftshader, llvmpipe, softpipe or software, you are in that mode. Real users on virtual machines and weak hardware get the same. As long as you measure on your own card, you are not seeing what Google sees.
Why it is not JavaScript
In the main-thread breakdown you look at categories: "Script Evaluation", "Style & Layout", "Other". With a heavy WebGL scene, "Other" can be bigger than "Script Evaluation". That is not a bucket to ignore. Other is usually the GPU work and pixel scans, which a script profile does not show at all.
Then there is the misleading label. A long task tagged gsap.min.js or requestAnimationFrame is usually not GSAP. In one frame, every requestAnimationFrame call lands in a single task that takes the label of whoever started it. The GSAP ticker registers first, so it takes the blame for someone else's work. Optimizing GSAP here is aiming at the wrong target.
The first frame is the most expensive frame
A scene usually draws one frame on start and then waits for the user to move. Google's measurement does not move the mouse, so it sees exactly that one frame. And that one frame compiles every shader program at once.
On one of my production builds the scene had fourteen programs: particles, arcs, several bloom layers, composite, output. Each compiled in the first frame, and after every compile the library asked the card for the result. Every such query waits until the card has worked through its whole command queue. The result: one 656 ms task on Google's machine and a score of 73 on desktop. Total Blocking Time sat around 700 ms. None of it was JavaScript in the sense most guides mean.
Cutting particles does not help, and it ruins mobile
The most common reflex is to lower the particle count "for performance". That misreads the problem on two levels.
First, particles are cheap. A point is one vertex. What is expensive is fill: bloom, transparency, overdraw, not the number of points. Cut the particles and you cut the cheap resource while leaving the expensive one untouched.
Second, an image made of particles has a resolution equal to their count. Text built from ten thousand particles looks different from the same text built from seventy thousand. When you give the phone ten times fewer particles "to make it lighter", the text goes blurry or sparse, and you still have not touched the real cost. On one build I raised the phone particle count from seven thousand to seventy-two thousand and the text finally looked sharp, while the score did not drop, because the problem was never the point count.
How to measure without chasing noise
Before you fix anything, you need a measurement that does not lie. Here are the three traps almost everyone falls into.
| What you do | Why it lies | What to do instead |
|---|---|---|
| Profile in DevTools on your own GPU | The second load has the programs cached on disk, the compilation disappears | A fresh profile with no cache, software rendering (--disable-gpu) |
| A single PageSpeed run | Noise of 7 to 10 points, the same file gives 80 and 95 | Median of three, a 7-point significance threshold |
| Looking at categories instead of tasks | "Other" tells you nothing about the cause | Read the list of long tasks with their start times |
The rule is simple. Measure in Google's conditions, not on your card. Read specific tasks, not categories. Treat a change smaller than seven points as chance, not success. Only with a measurement like that does it make sense to touch the scene.
What to do about it
The fix exists and it is repeatable. In short: the first frame must not compile programs, so you compile them ahead of time and warm them up in the background, in the right render conditions. Pixel loops and scene building cannot sit in one task at load. The phone gets the same particle count, only the shots without text are lighter. On the same build where I started at 73, I ended at 97 on desktop, with Total Blocking Time cut from 700 to 140 ms and the first frame from 656 ms to a few milliseconds, with no change to how the scene looks.
I packaged the full method, with copy-ready code, a measurement protocol and a PageSpeed script, as a ready-made skill for developers and coding agents. If you would rather hand the work to me, send the scene URL and I will tell you plainly where the cost sits and how much I will take off.
FAQ
Does a bad PageSpeed score on a WebGL scene hurt my Google ranking?
Google ranks mostly on field data from real users (Core Web Vitals: LCP, CLS, INP), not on the lab score. A <canvas> is not an LCP candidate, so a WebGL scene does not hurt LCP. It does hurt Total Blocking Time, a lab metric, and real INP if the scene blocks the thread during interaction. Worth fixing for the user and the score, without panic that one red lab number sinks the page.
Why is the long task labelled GSAP when I barely use GSAP?
Because a long task label points to the script that started it, not the one that did the work. All requestAnimationFrame calls in a frame go in one task and take the name of the first. The GSAP ticker registers early, so it takes the blame. The real weight is usually shader compilation in the first frame. Do not optimize GSAP on that basis.
Can I just move the scene to a Web Worker or OffscreenCanvas?
Usually not, and usually not as the first move. A Web Worker is a day of work, while the problem is most often solved by task ordering and compiling programs ahead of time, a change of a few hours. Reach for a Worker when you genuinely have heavy CPU work in the render loop, not to hide shader compilation from the measurement.
Can it be fixed without changing how the scene looks?
Yes, and that is the whole point. The method keeps the desktop image untouched to the percent, and the phone just as sharp, only smaller. It is about order and the way things are drawn, when programs compile and what sits in the first frame, not about cutting graphics.
Top comments (0)