Skip to content

Performance Measurements ​

MVT cost measurements: keeping Pixi containers in step with a model, reacting to changes, building and reusing containers, the scene passes, each hot path rule, memory and garbage collection, and the games and demos themselves. The tables come straight from this repo's benchmark suites, and each section says what the numbers mean.

Related: Why Performance Matters · Hot Paths · Benchmarking Methods · Reactivity: Why MVT Uses Polling


What Is Measured ​

Most benchmarks here time one frame: the model changes, then the view catches up. Nothing is drawn, so rendering is not included in any number.

  • Pixi container: one Container, whose properties (x, y, alpha) follow one model record, a plain object such as { x, y, alpha }. Most benchmarks use 1000 of them.
  • Dynamic and static properties: properties on each Pixi container, such as x and alpha. A dynamic property is updated every frame to reflect a value in the model, so it can change. A static property is assigned once when the scene is built, and never updated from the model after that. "3 dynamic" means x, y and alpha are all dynamic; "1 dynamic, 2 static" means only x is.
  • Changed per frame: the share of containers that change in a given frame. A container that changes gets a new value in every one of its dynamic properties, so this is also the share of dynamic properties that change. 0% is a scene at rest; 100% means every container changes every frame.

The ways of keeping the containers in step with the model:

ApproachHow the view learns about a change
MVT (hand-written)Each container's refresh method reads the model and assigns its properties, called by refreshView every frame. This is polling, as the rest of these docs describe it
MVT (JSX)The same polling, but built with this repo's JSX runtime (packages/pixi/src/jsx/), where each dynamic property is given as a function that reads the model
EventsThe model calls a listener for each record it changes, and the listener assigns the properties
Solid signalsThe model's values are Solid signals, with one effect per container that assigns its properties when they change
Model only, no viewJust the model's changes, to show how much of a frame they are

Each cell is the median of two runs, or three where the first two disagreed by more than 5%, each in its own Node process, rounded to 3 significant figures. Where the runs disagreed by more than 5% either way, the cell also shows half the spread between the fastest and slowest run, as a share of the median: 3.8 ±40% marks a noisy result. A cell without "±" had runs within 5% of each other. Every run's numbers are in the saved results. Times are in microseconds (µs); a frame at 60 frames per second lasts 16,667 µs.

Measured 2026-10-02 on Intel(R) Core(TM) Ultra 9 185H, win32 10.0.26200, Node.js 26.10.0 (V8 14.6.202.34-node.34), pixi.js 8.21.0, solid-js 1.9.15.

About the figures

Every figure on this page comes from the one machine above, in Node.js's V8 engine, with nothing drawn. Absolute times will differ on other hardware, in browsers, and in other engines. The ratios between approaches, and the patterns (what grows with scene size, what allocates), are what generally carry over. Re-run the benchmarks on your own machine to get your own numbers; see Benchmarking Methods.

The Short Version ​

  • Polling 1000 Pixi containers costs 5-15 µs per frame and leaves no garbage, however much changes.
  • Signals are cheaper when little changes, and dearer when a lot does. They win when fewer than about 4-10% of containers change per frame, cost 7-13x as much when everything changes, and leave garbage on every change.
  • Scene size matters more than it looks. Past a few thousand containers the cost per container climbs; 100,000 containers at rest cost about 4.2 ms per frame. Skipping inactive subtrees with SKIP_DESCENDANTS is the remedy.
  • Four hot path rules matter a lot, two not at all (in V8). Strings built per game object, Object.values(), array methods and recomputing unchanged values cost several to tens of times more, and the first three leave kilobytes of garbage per frame. for...of and returned tuples cost nothing extra.
  • This repo's games take 4-9 µs per frame before drawing.

Keeping Containers in Step ​

1000 Pixi containers, each following one model record, as the share changed per frame goes from nothing to everything.

1000 containers, 3 dynamic properties each: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Changed per framemodel only, no viewMVT (hand-written)MVT (JSX)eventsSolid signals
0%04.9613.300.01
1%0.015.75140.092.5
10%0.126.1214.61.0923.3
50%0.617.8516.35.28117
100%1.29.818.96.67244

1000 containers, 1 dynamic property and 2 static properties each: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Changed per framemodel only, no viewMVT (hand-written)MVT (JSX)eventsSolid signals
0%04.178.9600.01
1%0.014.329.070.031.07
10%0.084.489.180.3610.1
50%0.385.2710.3249.1
100%0.86.6611.94.1895.5
  • Polling costs about 4-13 µs per frame for 1000 containers at rest, under 0.1% of a frame. It rises to 7-19 µs when everything changes, mostly because Pixi's setters then have real work to do.
  • Signals cost almost nothing at rest, and about 240 ns per changed container (100 ns with 1 dynamic property). They are cheaper than polling until about 2-5% of containers change per frame with 3 dynamic properties, or 4-9% with 1. Past that they cost more, and when everything changes they cost 8-24x as much.
  • Events are the cheapest here however many containers change, because the model only notifies about what changed and nothing is scanned. That is a timing, not a recommendation: Events and Signals covers what events cost in design, and at 1000 containers the difference from polling is a few microseconds per frame.
  • The JSX runtime costs 1.8-2.6x as much as hand-written refresh methods: about 5-9 ns more per container, most of it per container rather than per property. Its refresh methods are closures, with no generated code, so they work under any Content Security Policy; in scenes of thousands of elements, where the cost matters, they come within 1.1-1.3x of code generated per element shape, which an earlier runtime used. A static property costs nothing per frame, so in JSX, pass values that never change as plain values rather than getters.
  • The model's own changes are small: at most 1.2 µs per frame, even with every record changed each frame.

Scaling ​

The same scene, with 3 dynamic properties per container, from 100 to 100,000 containers. These tables divide each frame's time by the number of containers, so a flat column means the cost grows in proportion.

Nothing changed: time per frame per container (ns; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

ContainersMVT (hand-written)MVT (JSX)Solid signals
1004.812.80.15
1,0004.9713.10.01
10,00011.316.60
100,00037.149.80

Every container changed each frame: time per frame per container (ns; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

ContainersMVT (hand-written)MVT (JSX)Solid signals
1009.9618.6235
1,0001020.4239
10,00014.927.2301
100,0004760.7435
  • The cost per container is flat up to about 1,000 containers, then climbs. Polling a scene at rest costs about 5 ns per container at 1,000, 12 ns at 10,000, and 42 ns at 100,000: past a few thousand containers, the scene no longer fits in the CPU's caches. Do not multiply a small scene's numbers up to a large one.
  • At 100,000 containers, polling a scene at rest takes about 4.2 ms per frame with hand-written refresh methods, and 5.5 ms with the JSX runtime: a quarter to a third of a 60fps frame, before anything is drawn. Scenes that large are where skipping inactive subtrees pays.
  • Signals slow down at scale too. With everything changed each frame, each container costs 255 ns at 1,000 containers and 435 ns at 100,000, which is 44 ms per frame: more than two and a half frames' worth.

Every container changed each frame: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

ContainersMVT (hand-written)MVT (JSX)Solid signals
10011.8623.5
1,0001020.4239
10,0001492723,010
100,0004,6906,07043,500

Occasional Changes and Derived Values ​

1000 Pixi containers again. In the first table, each follows a value that changes only occasionally, such as a level or a score, and updates two properties when it does. Polling here means change detection: comparing with last frame's value, either by hand or with the watch() helper.

A value that changes occasionally, updating two properties when it does: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Changed per framecompare by handwatch()eventsSolid signals
0%4.021200.01
1%4.16120.111.11
10%4.6212.91.2110.3

In the second, each container shows a value computed from 8 model values, such as a total. Polling either recomputes it every frame, or uses watch() on all 8 values and recomputes only when one changed. Signals use a createMemo.

A property computed from 8 model values: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Changed per framerecompute every framewatch()eventsSolid signals
0%8.1968.600.01
10%7.79690.8259.8
100%11.575.68.05591
  • watch() costs about 8 ns per watched value per frame, nearly three times as much as comparing by hand. It allocates nothing (see Memory), so the cost is in the calls. The likely cause, not yet confirmed with a profile, is that every watcher calls its getters from the same line of poll(), which the engine cannot inline when it sees thousands of different getters there. At game scale this is still small: 1000 watched values cost about 12 µs per frame.
  • Recomputing a cheap derived value every frame is cheaper than detecting whether it needs recomputing. Summing 8 numbers costs less than checking 8 numbers for changes. Change detection pays when the work it skips is expensive, such as rebuilding text or restructuring the scene, not when the work is a little arithmetic.
  • A memo per container is expensive when its inputs change often. With every input changed each frame, Solid's memos cost about 590 ns per container per frame, against about 10 ns to recompute by polling.

Building, Destroying and Reusing Containers ​

The first table is the whole life of one Pixi container with 3 dynamic properties: building it and its model record, bringing it up to date once, and destroying it. A bare new Container() and destroy() is included for scale.

One Pixi container and its model record, from construction to destruction (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Scenariobare containerMVT (hand-written)MVT (JSX)eventsSolid signals
build, first update, destroy0.1250.2020.4910.1250.398

The second is a pool of short-lived items, such as bullets, held in a SlotList. About 500 are alive at once. Either <List> shows the slots, building one container per slot and reusing it for every later item, or a hand-written view builds a container for each new item and destroys it when the item goes.

About 500 short-lived items alive at once: time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

New items per frame<List> over a SlotList (reuses containers)build and destroy a container per item
515.719.8
5017.591
  • A container costs about 0.1-0.5 µs over its life, most of it Pixi's own Container. The JSX runtime and signals cost about 3-4x a bare container. Building a few containers per frame is cheap; building hundreds is not.
  • Reusing containers makes a pool's cost independent of how fast items come and go. With <List>, the frame costs about 17-18 µs whether 5 or 50 items appear per frame. Building and destroying costs 21 µs at 5 per frame and 95 µs at 50, and it allocates far more (see Memory).

What refreshView Costs ​

What refreshView itself costs, apart from the work the refresh methods do: each one here does almost nothing. It is compared with a plain recursive walk over the tree, which calls every refresh method it finds, and with Pixi's own onRender callbacks. The last scenario has 100 groups of 100 containers, 90 of the groups inactive, either only hidden or returning SKIP_DESCENDANTS from their own refresh method.

refreshView and its alternatives (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

ScenarioApproachTime per frame (µs)Method calls per frame
20,000 containers, 200 with a refresh methodplain recursive walk197200
refreshView0.6200
2,000 containers, all with a refresh methodplain recursive walk8.762,000
Pixi onRender1.852,000
refreshView5.342,000
2,000 containers, all with a refresh method, 100 replaced per frameplain recursive walk26.72,000
refreshView1052,000
100 subtrees of 25 containers without a refresh method, detached and re-attached per frameplain recursive walk39.81
refreshView8.321
100 containers added and removed per frame, no refreshView@mvtjs/pixi not imported110
@mvtjs/pixi imported11.10
10,000 containers with a refresh method each, in 100 groups, 90 groups inactiveinactive groups hidden (visible = false) only11710,000
inactive groups return SKIP_DESCENDANTS9.411,090
  • refreshView visits only the containers that have a refresh method. It keeps a cached method list of them, so in a large scene where few containers have one, it is hundreds of times faster than walking the tree: 0.65 µs against 206 µs for 200 refresh methods among 20,000 containers.
  • Replacing many containers that have a refresh method every frame is its worst case. 100 replacements per frame make refreshView rebuild its method list every frame, walking the tree to do it: 127 µs, against 29 µs for a plain walk. The packages/pixi/src/ README explains why this is accepted.
  • It costs about 2 ns more per call than Pixi's onRender, which is what checking for containers removed during the call costs.
  • Skipping inactive subtrees pays at scale. Returning SKIP_DESCENDANTS from 90 inactive groups cut the frame from 128 µs to 10 µs. Hiding a container with visible = false does not stop its refresh methods from running.
  • Importing @mvtjs/pixi costs a tree that never uses it about 6 ns per added and removed container, for tracking changes to the tree.

The Hot Path Rules ​

Each rule on the Hot Paths page, written two ways that compute the same result: the pattern the rule warns against, and the one it recommends. The work is typical of a game's frame, over 1000 game objects (plain objects with a position, a velocity and a few stats).

Time per frame (µs; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Rule (per frame, over 1000 game objects unless stated)AvoidPrefer
.filter().map() vs an index loop3.110.85
for...of vs an index loop0.90.91
template-string key into a Map vs arithmetic index into an array50.14.21
numeric key into a Map vs index into an array5.464.27
forEach with a new closure vs an index loop2.462.46
Object.values() vs reading properties481.53
returning a [col, row] tuple vs an out-parameter4.674.49
summing 1000 scores every frame vs caching the sum0.360.02
100 Text labels: set every frame vs only on change0.660.21

Bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Rule (per frame, over 1000 game objects unless stated)AvoidPrefer
.filter().map() vs an index loop24,5000
for...of vs an index loop00
template-string key into a Map vs arithmetic index into an array48,0000
numeric key into a Map vs index into an array00
forEach with a new closure vs an index loop560
Object.values() vs reading properties80,0000
returning a [col, row] tuple vs an out-parameter00
summing 1000 scores every frame vs caching the sum00
100 Text labels: set every frame vs only on change1 ±44%1 ±26%
  • Four rules clearly matter. Object.values() per game object is 32x slower than reading the properties and leaves 80 KB of garbage per frame. A template-string key is 12x slower (48 KB), .filter().map() 4x (24 KB), and recomputing an unchanged sum 20x, though that last one costs only 0.4 µs.
  • Two make no difference in V8. A for...of loop over an array runs as fast as an index loop and allocates nothing. A function returning a [col, row] tuple allocates nothing either: once it is inlined, the engine never builds the array.
  • Two matter a little. A Map with numeric keys is 1.3x slower than an array but leaves no garbage. A closure created per frame costs 56 bytes and no measurable time.
  • Pixi's Text already skips unchanged text. Setting the same string every frame costs about 7 ns per label, and V8 reuses the strings of small numbers, so it allocates nothing. Checking for a change first is still about 3x cheaper.

Memory and Garbage Collection ​

Garbage is memory a frame allocates and then drops. The engine reclaims it by pausing to collect it, and a game that allocates every frame collects often. These tables show the bytes each frame allocates, how often the engine collected over one simulated minute, and how much memory each container keeps alive. "MVT (hand-written, wasteful)" is the hand-written approach, plus building a string and an array every frame, as the hot path rules warn against. It is there to show that the measurement sees allocation when it happens.

1000 containers, 3 dynamic properties each: bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Changed per frameMVT (hand-written)MVT (JSX)eventsSolid signalsMVT (hand-written, wasteful)
0% changed00010496,600
10% changed09062,60099,000
100% changed1 ±21%90631,00096,700

1000 containers reacting to a value that changes occasionally, 10% changed per frame: bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Changed per framecompare by handwatch()eventsSolid signals
10% changed00030,600

About 500 short-lived items, 50 new per frame: bytes allocated per frame (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

New items per frame<List> over a SlotListbuild and destroy per item
50 new per frame5,60070,000

1000 containers, 3 dynamic properties, all changed every frame, for one simulated minute (3600 frames) (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

ApproachYoung-generation collectionsFull collectionsTime in collections (ms)
MVT (hand-written)000
MVT (JSX)000
events000
Solid signals271045.2 ±8%
MVT (hand-written, wasteful)4106.9

About 500 short-lived items, 50 new per frame, for one simulated minute (3600 frames) (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

ApproachYoung-generation collectionsFull collectionsTime in collections (ms)
<List> over a SlotList500.8
build and destroy per item1504.9

Memory kept alive per Pixi container and its model record (bytes; median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

Measurebare containerMVT (hand-written)MVT (JSX)eventsSolid signals
per container7128831,1908742,550
  • Polling allocates next to nothing. Hand-written refresh methods and watch() allocate nothing per frame, and the JSX runtime a constant 8 bytes per frame, however much changes. Over a simulated minute with everything changed each frame, the engine never needed to collect.
  • Signals allocate on every change. Solid's effects left about 630 bytes of garbage per changed container per frame (330-380 with solid-js 1.9.11 under Node 22), and about 100 bytes per frame even at rest. With everything changed each frame, that is 272 collections a minute, about 32 ms of pauses: six times the garbage of the deliberately wasteful refresh methods.
  • Events allocate nothing in this benchmark, because each record's listener is created once and called with the record.
  • These scenes use whole numbers. V8 stores whole numbers without allocating, but can box a fractional number in a small heap object when it stores or passes one, and whether it does can depend on code nearby. The JSX runtime keeps fractional values unboxed in the attributes it writes only on a change, such as width: 1000 sprites with a fractional width allocate nothing per frame, changed or not.
  • Reusing containers avoids most of a pool's garbage. With <List> over a SlotList, the only allocation is the new model records (5.6 KB per frame at 50 new items). Building and destroying a container per item allocates about 70 KB per frame, and collects about three times as often.
  • A Pixi container is already about 710 bytes. Following a model record with a hand-written refresh method or an event listener adds about 170 bytes, including the record itself; the JSX runtime adds about 470, and signals and an effect about 1,800.

The Games and Demos ​

This repo's games and demos as they ship, each started through its entry and run under Node with nothing drawn. The games are driven by a fixed pattern of simulated input: directions changing every half second and buttons pressed every second or so. The demos run unattended, as they do before anyone touches them. Each frame is what the game loop runs: the session's update (its models), then updateView and refreshView on the stage. The first 10 seconds are a warm-up, and times are averaged over the minute after.

Time per frame, averaged over one simulated minute (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

KindNameModels (µs)updateView (µs)refreshView (µs)Total (µs)Pixi containersUpdate and refresh methods
GameAstrovoid0.85 ±13%0.33 ±10%4.03 ±9%5.12 ±9%4622
Burrow Bust1.28 ±6%0.35 ±16%2.744.373714
Crumb Chase0.180.49 ±12%4.06 ±6%4.79 ±6%23411
Dojo Duel1.65 ±20%0.4 ±13%1.93 ±7%3.94 ±12%276
Fuel Run1.09 ±18%0.54 ±8%3.154.828442
Galaxy Raiders3.64 ±13%0.42 ±46%3.347.42 ±7%7938
Kwazy Cactii0.17 ±8%0.71 ±14%8.559.630676
DemoBoids507 ±11%0.86 ±7%15.1523 ±11%31333
Falling sand1.85 ±7%0.74 ±6%1821843,8903,790
Reordering lists0.2 ±124%0.54 ±19%2.3 ±10%3.32 ±8%4637
  • Each game takes 4-9 µs per frame, well under 0.1% of a 60fps frame, before drawing. Drawing is not measured here, but is likely to cost far more.
  • refreshView takes the larger share in most games, since that is where the views read the model and set their properties. The models take 0.2-4 µs, and updateView under 1 µs; in Galaxy Raiders, with more going on in its model, the models take longer than the refresh.
  • Two demos cost far more than any game, for different reasons. Falling sand has a sprite per grain, about 3,800 containers once its opening scene settles, and its refresh takes about 160 µs, about 41 ns per container with nothing moving. How that grows with the number of grains is in the falling-sand-scaling results: about 2.2 ms at 20,000 grains. Boids takes about 0.5 ms, almost all of it in its model, which compares every pair of its 200 boids each frame.
  • The games allocate a little every frame, from tens of bytes to about 1 KB. The hot path rules aim for none, and the allocation benchmark is a way to find where it comes from. At these rates the engine collects at most three times a minute, for under a millisecond in total.
  • Fuel Run and Crumb Chase used to allocate 2-3 KB per frame. Fuel Run's came from views redrawing graphics every frame (each explosion, and the fuel bar), now drawn once and then scaled or resized. Crumb Chase's came from its model: every one-tile step of the mouse or a cat started a GSAP tween. The steps are now plain arithmetic advanced by update(deltaMs), and the model allocates nothing, with its update down from about 6 µs to 0.2 µs.
  • Boids used to allocate about 360 KB per frame, and the engine collected 88 times a minute. Most of it was the view redrawing all 200 boids into one Pixi Graphics every frame, which makes Pixi build new shape data each time; each boid is now its own Graphics, sharing one triangle, positioned and turned. The rest came from sliders redrawing unchanged, and from numbers the engine had to box: writes to a boid record that had getters, a random-number function called per boid, and Pixi's rotation setter. It now allocates about 34 bytes per frame, and its frame time fell from about 1 ms.

Bytes allocated per frame (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

KindNameBytes per frame
GameAstrovoid104 ±7%
Burrow Bust234
Crumb Chase443
Dojo Duel939
Fuel Run352
Galaxy Raiders224 ±21%
Kwazy Cactii19
DemoBoids34
Falling sand17
Reordering lists204

Garbage collections over one simulated minute (3600 frames) (median of 2 runs, or 3 where the first 2 disagreed by more than 5%, each in its own process; ± marks runs that disagreed by more than 5%)

KindNameYoung-generation collectionsFull collectionsTime in collections (ms)
GameAstrovoid000
Burrow Bust000
Crumb Chase000
Dojo Duel1 ±50%00.1 ±48%
Fuel Run000
Galaxy Raiders100.1 ±83%
Kwazy Cactii100.4
DemoBoids000
Falling sand000
Reordering lists000

What Is Not Measured ​

  • Rendering. Nothing is drawn, so Pixi's transform updates and draw calls are not in any number. When values change, rendering is likely to cost more than anything measured here.
  • Browsers. Everything is V8 under Node on one machine. Other engines, and the same engine in a browser, may differ; ratios travel better than absolute times.
  • Textures and text. The games and demos run with every texture replaced by a 1x1 white texture, because loading a spritesheet needs a browser, and with text widths estimated from the font size, because measuring text needs a canvas.

To re-run any of this, see Benchmarking Methods - Running the Repo's Benchmarks.