Why moving sprites are slow in the browser
A GPU can draw millions of sprites, but most browser games stall in the tens of thousands, and the reason is JavaScript, not the GPU. Games, data visualisations and particle effects all share this job: draw lots of small images, called sprites, in new positions sixty times a second.
We build spryte, an open-source 2D renderer for the web. On an open benchmark it draws moving sprites 2–4× faster than PixiJS, the most widely used library for this job, from about a ninth of the JavaScript. This post explains where the time actually goes, what we changed, and how you can check our numbers on your own machine.
The budget: 16.7 milliseconds
A screen refreshing at 60 Hz gives each frame 16.7 ms. In that time the page has to run its game logic, then tell the GPU what to draw, then let the GPU draw it. Miss the deadline and the animation stutters.
The GPU is rarely the problem for 2D. Drawing a small textured rectangle is about the cheapest thing a GPU does. The problem is the conversation before it: JavaScript has to describe every sprite to the GPU, every frame. If describing one sprite costs 100 nanoseconds, 100,000 sprites cost 10 ms, which is most of the budget gone before a single pixel is drawn.
So the real question for a 2D renderer is: how much JavaScript work does each sprite cost?
Two ways to describe a scene
Most 2D libraries, including PixiJS, use a scene graph (also called retained mode). You create an object for each thing on screen, attach it to a tree, and change its properties over time. The library walks the tree every frame: it updates each object's position relative to its parent, works out its bounds, decides how to group it with others, and writes its corners into a buffer for the GPU.
That's a very good design for an interface or a mostly still scene. It handles nesting, hit-testing and events for you, and if nothing changes, a lot of work can be skipped.
But in a game, things move. When every object changes every frame, all of that per-object bookkeeping runs for every sprite, every frame.
The other approach is immediate mode. There are no objects to keep. Each frame you simply say "draw this image here", in order, and the renderer forgets it afterwards. spryte works this way:
draw.begin("#111", 320, 180);
for (const e of enemies) draw.sprite(enemyImage, e.x, e.y, { rotation: e.angle });
draw.end();There's no tree to walk and nothing to keep in sync, so the cost per sprite is just the cost of writing its numbers into memory the GPU will read.
Making each sprite cheaper
Even immediate mode isn't free. When we profiled spryte's original renderer, each sprite still cost about 70 ns of JavaScript, and PixiJS's specialised particle container was beating it. The time was spread thinly over reading options, transform maths and writing 13 numbers per sprite. No single line was slow, so tuning lines couldn't fix it. Two structural changes did.
1. A bulk call. Games often draw many copies of the same image: bullets, particles, coins. So spryte has one call that takes the image once and a whole array of positions:
const positions = new Float32Array(2 * MAX); // x, y pairs your game already updates
draw.sprites(spark, positions, count);The shared work, such as which texture and which part of it, is done once per call instead of once per sprite.
2. Sending less to the GPU. Each sprite used to send 52 bytes to the GPU: a full transform, texture coordinates, colour and more. For copies that only differ in position, that's mostly repeated data. spryte's bulk path sends 8 bytes per sprite, just x and y, and the GPU fills in the rest from values sent once per call. Better still, if your positions are already in a Float32Array, spryte hands that array to the GPU as it is. There's no JavaScript loop over sprites at all.
Real particles also vary in size, rotation and colour, so those can be passed as extra arrays, each uploaded as is. Colours stay packed as one 32-bit number and are unpacked on the GPU.
None of this is new to graphics programmers. Native game engines have done it for decades; it's called instancing. What's unusual is packaging it as a tiny, plain browser library that you can use without a game engine.
Measuring it without fooling ourselves
Renderer benchmarks are easy to rig by accident: leave the rival library on a slow default, pick a scene that suits your design, or time one lucky run. We make spryte, so we have every reason to want a flattering result, and every reason to distrust one we wrote ourselves.
So we built 2dbench, an open benchmark that any 2D renderer can plug into. Its rules:
- Same work. One shared, seeded simulation moves everything. Libraries only draw.
- Same pixels. Before anything is timed, every library draws the same frame, and the pixels are compared with PixiJS's output. If a library draws something different, the run fails.
- Real cost. Each frame is timed until the GPU has finished, so nothing hides behind the screen's refresh.
- No luck. Several rounds, in rotating order, reported as the median with the spread.
- Everything published. Every result file records the GPU, browser, CPU and even the power plan. If you know a faster way to set up a library, a pull request will change it.
There's more detail in Benchmarking 2D web renderers without fooling yourself.
The results
Most moving sprites each library can draw at 60 fps, on a laptop with an NVIDIA RTX 5060 in Chrome:
| Scene | spryte | PixiJS |
|---|---|---|
| Moving sprites, bulk | 800,000 | 200,000 |
| Particles that change size, rotation and colour, bulk | 400,000 | 100,000 |
| Moving sprites, one call each | 100,000 | 50,000 |
| A retro first-person maze drawn as thousands of texture strips | 10,000 enemies | 2,000 |
Counts are tested in steps (50k, 100k, 200k…), so each figure is the last step that held 60 fps. The frame-time charts and raw data are in the 2dbench README. The maze scene is a Wolfenstein 3D-style raycaster, and it's the hardest case for a scene graph, because nearly every object changes every frame.
spryte also ships about 10 kB of JavaScript at startup (compressed), against about 88 kB for PixiJS.
What this doesn't mean
- PixiJS isn't badly made. It does far more: filters, text layout, interaction, an ecosystem of plugins. Its per-object design is a sensible trade-off, and the cost only shows when huge numbers of things move at once.
- Still scenes are much closer. If most of your scene doesn't change, a scene graph can skip work, and the gap shrinks.
- Your game won't run 4× faster. A real game also spends time on its own logic, physics and sound. Faster drawing gives that time back; it doesn't make the rest faster.
- It's mostly one machine so far. We have desktop Chrome on Windows and Linux, and one iPhone. Safari on Mac, Firefox and Android phones are barely covered.
That last one you can help with.
Try it
- Run the benchmark on your device: debuffjp.github.io/2dbench. It takes a few minutes, needs no account, and adds your phone or laptop to the public results.
- Use spryte:
npm install spryte. It's MIT licensed with no dependencies, and falls back to Canvas 2D where WebGL2 isn't available. It's also the renderer inside debuff, our toolkit for 2D web games. - Prove us wrong: if you know a faster way to set up PixiJS, or any other library, open a pull request on 2dbench. If our lead shrinks, we'll publish that too.
The longer, more technical version of this story, from the first profile to the 8-byte instance format, is in How spryte got fast.