-
Notifications
You must be signed in to change notification settings - Fork 662
Modernization Updates 2026
GPU.js was dormant for a long time. The last release before 2026 was 2.11.2 in January 2021. This page summarises the work done since the project resumed in July 2026 — what was fixed, what now guards it, and why the dependency reduction in particular matters.
Sixteen releases, 2.17.0 through 2.19.9, 131 issues closed, and the open issue list down to 63 deliberate keeps.
Twenty-two library bugs have been fixed. They fall into a few clear groups.
-
new GPU()threw in Chrome and Edge (#844, #820). Those browsers define a nativewindow.GPUfor WebGPU, which blocked the bundle from claiming the global. The bundle now exports the class itself and claims the name over the WebGPU interface. - Strict-mode and ES-module loads threw (#639). The global was installed as a getter with no setter, and the UMD wrapper assigns its export immediately afterwards — which throws in strict mode. It now has a write-swallowing setter.
-
The published bundle could be corrupted in transit (#743, #744). acorn's
unicode identifier tables were emitted as raw bytes, so serving
dist/without a matching charset broke kernel parsing. All four artifacts are now verified ASCII-only on every build.
This is the most consequential group, because nothing throws — you just get the wrong answer.
- Kernels indexing one array by another returned garbage on Windows (#300). This one is remarkable enough to get its own section below.
-
Every video passed to a WebGL kernel sampled arbitrary texels. The
texture was sized from
video.width/height— the presentational attributes, which are 0 on a detached element — instead ofvideoWidth/videoHeight, so the shader divided by a 0×0 size. Constant indices often looked right by luck; computed ones returned different garbage on every run, which made the video tests look flaky for years when they were in fact broken. -
The CPU backend read images and video a shade off. Its readback canvas
lacked
willReadFrequently, so browsers kept it GPU-backed, andgetImageDataon a GPU-backed canvas is not bit-exact with the decoded image: a pixel that decodes to 253 came back 252. The CPU and WebGL backends disagreed about every image; they now agree exactly, verified against the browser's own decoder. -
Float packing was inexact on Apple silicon (#659).
encode32/decode32leaned onexp2/log2/pow, which are approximate on some GPUs, and 1–2 ulp of error corrupts the packed bytes. Rewritten with exact constant-multiply arithmetic. The macOS suite went from 157 failures to 12 on this fix alone. -
divWithIntCheckbroke negative division (#742). -
Int8ArrayandInt16Arrayarguments read as garbage (#701).getBitRatioreported 1 and 2 while signed arrays are transferred asFloat32Array, so the texture was sized and decoded at the wrong width. -
/silently truncated to an integer divide. JavaScript has no integer division, but the transpiler emitted one, sothis.thread.x / 64returned 0 for every thread and graphical kernels rendered flat. -
>>lost a bit on even operands.2 >> 1returned 0,8 >> 1returned 3, because the divisor came from an approximatepow(). Odd operands had enough slack to survive, which made the breakage look arbitrary. -
Bitwise
~ignored the sign of its operand, so~(-1)gave -2 and the common~~xtruncation idiom never round-tripped.
-
i % 2 === 0failed to compile on hardware where integer division is already accurate — desktop Chrome, Firefox and Edge.%was typed as an integer but lowers to a float-returning GLSL helper, producing a comparison GLSL rejects. -
Internally-defined matrices could not be indexed dynamically on WebGL1.
getMatrix()[y][x]with computed indices failed shader compilation with "Index expression can only contain const or loop symbols" — GLSL ES 1.00 forbids a variable index into any container, matrices and vectors included. Loop counters are legal indices, so the compiler now walks the matrix with a constant-bound loop and selects by comparison; constant indices keep the direct form. Invisible to the old test suite because headless-gl's compiler accepts the illegal form. -
Every kernel threw on WebGL1 without
OES_texture_float.precisionwas never defaulted on such a device, so the backend was unusable rather than degraded, with no fallback.
Serialized kernels — the feature that lets you compile once and ship the generated source — had four independent bugs, none reachable by the Node test suite because the modes they affect only run in a real browser:
-
Every
elsebranch was silently dropped while flattening helper functions. The regenerated kernel parsed, ran, and returned wrong answers whenever a helper branched. -
Mapped kernel outputs read back as all zeros. The generated
toArray()reused the kernel's draw framebuffer, where each subkernel texture is still bound to its color attachment; binding one to attachment 0 there reads nothing. The live kernel reads through a dedicated framebuffer — the generated code now does the same. -
A bare
let x;declaration crashed the serializer (null initializer). -
Expression-bodied arrow functions were corrupted —
(a, w) => a.subarray(0, w)lost its=>and became syntactically invalid, and the same replacement mangled the first nested callback inside ordinary functions.
-
Dynamic-output kernels leaked memory (#841). Every
setOutputleaked the replaced output texture into the kernel's cache, retained until destroy. -
GPU.destroy()leaked the WebGL context entirely for single-kernel instances, and left half the kernels alive otherwise — the destroy loop was mutating the array it was indexing. Browsers cap contexts at around 16 and then start evicting live ones. -
enableVertexAttribArraywas passed -1 whenever the GLSL compiler removed an unused attribute, raisingINVALID_VALUEon every draw.
-
Array-returning kernels were broken in
devmode (#719). -
createKernelis now typed as accepting a string source. The runtime always accepted one, but TypeScript users could not use the form — and it is the only form that works on React Native, because Hermes discards function source. -
Math.random()accepts a seed (#850), making runs reproducible.
#300 was filed in 2018:
lookup[input[this.thread.x]] returns the wrong value, while the same kernel
with a temporary variable in between works. It was closed as "not a bug". It
was a bug — in Microsoft's shader compiler — and it affected every GPU.js
kernel that indexes one array by another, in every browser on Windows, for the
project's entire life.
The chain of custody, because each link was verified rather than assumed:
- GPU.js compiles both array reads through one generated GLSL helper that
takes the texture as a
sampler2Dparameter, so the kernel becomes a call to that helper nested inside another call to the same helper. - ANGLE — the layer every Windows browser uses to run WebGL on Direct3D —
translates that GLSL to HLSL correctly. Verified two ways: building
ANGLE's translator standalone from source, and reading the translated HLSL
off an affected machine at runtime via
WEBGL_debug_shaders. The samplers arrive as distinct constant indices. -
FXC (
d3dcompiler_47.dll), Microsoft's closed-source HLSL compiler, miscompiles it. Compiling that exact HLSL with the copy of FXC shipped inside Chrome's own installer shows the damage in the bytecode: the second texture's register is absent from the compiled shader entirely, and both sample instructions read the first texture. A temporary between the calls, a differently-named identical function, or the rawtexture2Dbuiltin all compile correctly — the bug is specific to the same function inlined into itself. - The same nesting appears for
array[a[x] / b[x]]-style kernels, which had been a separate family of "integer division as index" failures. Same root cause, two symptoms.
The fix: the transpiler now hoists a texture read used inside an index expression into a temporary — the shape FXC compiles correctly — and emits byte-identical GLSL for every kernel that does not need it. On Windows this took the full browser suite on Chrome and Edge from 12 failures to zero.
Because FXC itself cannot be fixed, the workaround was also implemented in ANGLE, where a translator pass benefits every WebGL application, not just GPU.js: issue, with a standalone repro page, the FXC bytecode evidence, and a patch with an end-to-end test submitted upstream.
GPU.js is a transpiler whose output is executed by a GPU driver, so its
behaviour depends on hardware. For most of the project's life the tests ran only
against headless-gl, a software renderer — which meant an entire class of bug
was structurally invisible.
Continuous integration was added first (#515): Linux, Node 22, xvfb. Because the suite has platform-dependent failures on Mesa, it compares against a recorded baseline and fails only on new failures, so the signal is meaningful rather than perpetually red.
The browser suite runs in real browsers. headless-gl is WebGL 1.0 only, so
WebGL2Kernel — the backend every current browser actually selects — never
executed under CI at all: about 1,070 tests silently skipped. A
Playwright runner now drives the full 3,699-test
QUnit suite in real Chromium from Node (npm run test:browser, about two and a
half minutes), reporting one result per module. The harness is crash-tolerant:
a test that dies in a callback QUnit cannot reach — an image onload, a lost
context — used to hang the whole suite forever; it is now failed, attributed,
and stepped past, because one broken test must not cost the other three
thousand results.
Real hardware. Every push runs smoke and visual-regression suites on nine real targets — physical phones and tablets, not emulators, because emulators do not reproduce the mobile GPU drivers that actually break things:
| Real devices | Desktop browsers |
|---|---|
| iPhone 15 Pro (iOS 17) | Chrome / Windows 11 |
| iPhone 14 (iOS 16) | Firefox / Windows 11 |
| iPad Pro 12.9 (iOS 16) | Edge / Windows 11 |
| Samsung Galaxy S23 (Android 13) | Safari / macOS Sonoma |
| Google Pixel 7 (Android 13) |
The visual suite renders a flat fill, a gradient and a Mandelbrot and compares
each GPU render against the same device's CPU render — GPUs disagree by a
least significant bit on ordinary rounding, so the CPU renderer is the only
reference that travels. The full QUnit suite also runs across the desktop
matrix on demand (npm run test:browserstack:qunit, about six minutes for all
four browsers in parallel).
npm run make # the devices load dist/, so build first
npm run test:browser # full suite, real Chromium, locally
npm run test:browserstack # real iOS/Android
npm run test:browserstack:qunit # full suite across desktop browsersFourteen of the twenty-two bugs above were found this way, and none of them could have been caught by a software-rendered CI runner — they only reproduce on particular hardware or particular driver stacks. Two were found by reading device console logs, which is worth knowing: browser consoles carry driver complaints that never surface as a test failure.
Where the suite stands, all environments:
| environment | failures |
|---|---|
| Node suite, macOS | 0 |
| Browser suite, Apple M1 (3,699 tests) | 0 |
| Browser suite, Chrome / Windows 11 | 0 |
| Browser suite, Edge / Windows 11 | 0 |
| Browser suite, Firefox / Windows 11 | 0 |
| Browser suite, Safari / macOS | 0 |
| Node suite, Linux/Mesa | baselined platform set only |
Testing on real devices is provided free to open source projects by BrowserStack.
The build was replaced in two deliberate steps, each independently verifiable.
Step 1 — gulp to plain Node scripts. The build accounted for 14 of 20
devDependencies, for a pipeline that ran in about two seconds, and only one of
its five tasks was really about bundling. scripts/ now does the same work by
calling the libraries the gulp plugins wrapped, at the same versions. Output was
verified byte-identical for all five artifacts, three times: against a
reference build, after removing the packages, and after a clean install.
Step 2 — browserify to rolldown. Smaller bundles, with behaviour verified rather than assumed:
| bundle | before (gzip) | after (gzip) | |
|---|---|---|---|
gpu-browser.js |
118,770 | 116,126 | −2.2% |
gpu-browser-core.js |
77,352 | 75,740 | −2.1% |
gpu-browser.min.js |
95,906 | 93,080 | −2.9% |
gpu-browser-core.min.js |
63,277 | 60,213 | −4.8% |
Equivalence was checked directly: identical export surface, the #639 setter intact, kernels producing the same values, both device suites green on all nine targets, and the full QUnit suite in a browser returning identical counts from both bundles.
@gpujs/benchmark made the same move, and gpu.rocks was migrated twice:
from Create React App to Vite — build time from about 60 seconds to under
three, dependency tree from roughly 1,900 packages to 325 — and then from
React 16 to React 19, with router, charting and every companion library
brought to currently-supported versions, verified page-by-page against
pre-upgrade renders.
This deserves its own section, because in 2026 it is not a theoretical concern.
Installed packages went from 717 to 296 — a 59% reduction.
That number is the point. Every package in a dependency tree is code that runs on a maintainer's machine and in CI, usually with a lifecycle script, usually from an author nobody on the project has ever evaluated. The attacks that keep hitting the JavaScript ecosystem — compromised maintainer accounts, malicious post-install scripts, typosquats, protestware — operate through transitive dependencies a project never chose directly and rarely audits. The most reliable defence is not to have them.
Where it came from:
-
The gulp toolchain — gulp, six
gulp-*plugins, bothvinylpackages,merge-stream,browser-sync: 267 packages for tasks that are a few dozen lines of Node. - browserify — a further 150 packages, replaced by rolldown's 4.
-
@gpujs/benchmarkindependently went from 114 transitive packages to 4.
@gpujs/benchmark also shows how lopsided this can get. Its entire lodash
dependency existed for two cloneDeep calls, both on objects loaded from a
.json file — 64 and 24 bytes, containing nothing but nested objects and
strings. structuredClone is exactly equivalent for that, and lodash turned
out to be 94% of the published bundle:
| bundle | with lodash (gzip) | with structuredClone (gzip) |
|
|---|---|---|---|
benchmark.js |
97,559 | 6,259 | −93.6% |
benchmark.min.js |
28,750 | 3,565 | −87.6% |
4.9 MB on disk to deep-clone 88 bytes of JSON. Its runtime dependencies are now
performance-now and readline-sync. For comparison, replacing the bundler
in the same repository was worth −2.8% — the dependency audit was worth thirty
times more than the tooling upgrade.
One fix deserves calling out on its own: ordered-read-streams was listed in
dependencies, not devDependencies. It existed solely so gulpfile.js could
merge two build streams — purely a build concern — yet every consumer of GPU.js
installed it into production. It is gone. The runtime dependency list is now
four packages: acorn, gl, gpu-mock.js, webgpu.
Alongside that, Dependabot alerts went from 51 to zero — zero open and zero
waved through. The last one is instructive: brace-expansion's only patched
version was a semver-major that also changed its module shape, so it could
neither resolve nor be forced without breaking the caller at runtime. The fix
was to move the consumer (minimatch 9 → 10) rather than the dependency,
gated on the build output staying byte-identical. One dependency was vendored
deliberately: gl-wiretap, whose unused filesystem-writing codegen produced a
critical-severity false positive against the minified bundle. The vendored copy
is byte-identical to upstream apart from those deleted lines, and the header
documents exactly what was removed and why.
Three things have changed about the environment GPU.js runs in:
- Attacks moved down the tree. It is rarely the direct dependency that gets compromised; it is the one seven levels below that nobody has looked at since 2019. A 59% smaller tree is a 59% smaller set of maintainer accounts that can be taken over on your behalf.
- Provenance is becoming table stakes. Consumers increasingly need an answer to "what is in this and where did it come from". Four runtime dependencies is an answer. Several hundred, plus a lockfile is not. And note where the wins actually came from: swapping bundlers moved single digits, while asking why is this dependency here at all moved 94%. New tooling is the smaller half of the job.
- Abandonment is itself a security posture. Unmaintained build tooling does not stay still — it accumulates advisories with no upstream fix, and the only remedies become pinning something vulnerable indefinitely or an emergency migration under pressure. Doing the migration deliberately, with byte-identical output as the gate, is the cheap version of a job that is otherwise expensive and urgent.
For a project that had been dormant for five years, this was the difference between "still installs" and "still defensible".
If you find a security issue in GPU.js, please do not open a public issue.
The project now has a security policy, and private vulnerability reporting is
enabled on gpujs/gpu.js and its sibling repositories:
- SECURITY.md — scope, what is and is not a vulnerability, and how to report
- Report a vulnerability privately
Worth reading before reporting, because it documents one boundary that catches
people out: kernel source is code, not data. createKernel accepts a raw
string and executes it, and addNativeFunction injects raw GLSL, so passing
untrusted source to GPU.js is equivalent to eval and is not a vulnerability in
the library. Kernel arguments are data, and anything that escapes them is very
much in scope.
Response window is 30 days. The policy also has a section on common automated scanner findings, if a tool has flagged something in the bundle.
For anything not security-sensitive, the issue tracker is the right place.