The game opened on a phone and the tab died on the loading screen, with the browser's own out-of-memory page where the match should have been. That is about as clear a bug report as this project gets, and it needs no profiler to act on. Something was asking for more memory than a phone has.

The something was sprite sheets, and loading all of them for both factions at the start of a match came to tens of gigabytes.
Where the sprite sheets come from
This game renders in 3D and in a classic top-down 2D mode, and the 2D mode's units are not hand-drawn. They are the same 3D models, rendered ahead of time into sheets of frames: eight facing directions, three camera angles, one sheet per unit per animation. It is the same instinct as baking animation into a texture pushed one step further, until the output is pixels rather than geometry. That produces a lot of files. The folder holds 12,616 PNGs and 11,494 compressed siblings across 261 unit folders.
The sizes follow from the same arithmetic. A 512 pixel frame, three camera angles and a decent frame count is tens of megabytes of pixels for one unit in one animation, and a match wants idle, walk, attack and death for every unit both players can field.
Better compression cut the download and did nothing for the crash
The first attempt was compression. The game had been fetching the PNG every single time and never touching the far smaller AVIF version the pipeline had been generating for months. For a goblin against dwarf match that is 3.2 GB of PNG against 444 MB of AVIF.
It now asks for formats in order, AVIF then WebP then PNG, falling back only when a file is genuinely absent or fails to decode, so a browser without AVIF support still works.
That is a real sevenfold saving on the wire and it did not fix the crash, for a reason worth stating slowly because it is the whole post.
An image file is compressed. The pixels a browser draws are not. Between the two sits decoding: the browser reads the file, works out what every pixel is, and holds the answer as a plain grid of four bytes per pixel — red, green, blue, alpha. That grid is what costs memory, and its size depends only on the image's width and height. A 2,048 by 2,048 sheet occupies 16.8 MB decoded whether it arrived as 140 KB of AVIF or 3 MB of PNG, because both decode to the same 4.2 million pixels.
So compression bounds bandwidth. It does not bound memory, and nothing you do to a file's encoding ever will.
The fix that shipped and had no effect
Worse, in the shipped game the compression saving was not happening at all.
One generated file records the content hash for every asset, and another records which compressed format won for each one. Both were excluded from the repository, on the reasonable theory that generated files do not belong there. The deploy rebuilds the hash file by scanning the local project folder, and models and sprite sheets live on the content network and are not in the repository, so they are not on the build server. No entry means no hash, which means no format swap, which means every sprite sheet fell back to a PNG. The 3.2 GB per match download that was crashing the tab was still happening in the shipped game, while the machine it was developed on worked perfectly.
The fix was to stop excluding those two files and commit the synced copies, which carry the network-only entries: 6,948 sprite keys in one and 3,344 in the other. The generator now merges its local scan on top of the committed file rather than replacing it, so network-only entries survive a build while local ones still get refreshed.
This is the same shape as a compressed version that exists locally but was never uploaded: the code was correct, the fallback was graceful, and the graceful fallback is what hid the fact that the feature was off.
A hard ceiling is the only thing that bounds memory
The game now counts the decoded bytes of everything it is holding and throws things away past a ceiling. Three details in that are the ones that matter.
The ceiling is scaled to the device rather than fixed, at roughly 150 MB per gigabyte the device reports, held between 400 MB and 1.1 GB so a four gigabyte phone does not try to hold a desktop-sized cache.
// Scale the ceiling to the device, and pick a fallback that survives.
// Safari does not implement navigator.deviceMemory at all, and Chrome rounds it
// down, so the fallback below is what most of the field actually gets.
const gb = navigator.deviceMemory ?? 4;
const capBytes = Math.max(400e6, Math.min(1100e6, gb * 150e6));The second detail is what gets thrown away first: whatever has gone longest without being drawn, not longest without being loaded. Every draw marks its sheet as freshly used, so a unit on screen refreshes itself every frame and can never be evicted, while a unit that walked off the far side of the map ages out. Re-fetching it on the way back is cheap now that the compressed version is actually being served, which is where the two fixes start compounding rather than competing.
The third is that dropping the last reference to a decoded image does not free it. It has to be explicitly closed, or you wait for the garbage collector, and you cannot wait for the garbage collector when you are already at the ceiling.
The check that tells you whether you need any of this takes ten seconds. Take your largest image, multiply width by height by four, and compare it to what the network tab says you downloaded. A 2,048 by 2,048 sheet is 16.8 MB in memory whether the file was 140 KB of AVIF or 3 MB of PNG. If that number, times however many you hold at once, is more memory than you are willing to spend, no compression change will save you, because compression is a property of the file and memory is a property of the pixels.
There is also a one-way pressure valve. Shrinking the ceiling immediately evicts down to it and stops new sheets loading at all, so units fall back to a single still image rather than churning through fetch-and-evict cycles against a ceiling they cannot fit inside. There is deliberately no way back up within a match, because a cache that recovers under pressure is a cache that thrashes.
The preload that fetched 85% of its work in order to throw it away
The ceiling landed and the loading screen started lying. The preload was still queuing idle and walk for every unit of both factions, somewhere between five and eight gigabytes, all at once. That is several times the ceiling, so the cache loaded and immediately discarded about 85% of it. The symptoms were a burst of thousands of requests, wasted bandwidth, constant churn, and a progress bar cheerfully reporting 108 of 108 sheets ready when a handful were actually in memory.
The preload now picks its list before it starts. It works out each sheet's decoded size from the recorded frame dimensions, takes every unit's idle first and then walk until it has filled 80% of the ceiling, and loads exactly that in bounded batches. Everything else loads on first appearance, behind a single still frame for the one frame it takes. The number on the loading bar is now the number of sheets that will still be there when the match starts.
Shrinking, and the reason it was safe
The last change that day cut the sprite folder from 4.9 GB to 2.6 GB, and the interesting part is why that was mechanical rather than risky.
A uniform reduction was applied per unit, with a target frame size chosen by how much ground the unit occupies: a one-tile unit goes to 256 pixels, a two-tile one to 384, a three-tile one to 448, and anything larger is left alone. Every direction and angle was resized, and the recorded frame size and content extents were multiplied by the same factor. Column and row counts, frame counts, camera angles and world extents were untouched.
That is safe because the renderer works out how big to draw a sprite from the ratio between those recorded numbers, and a uniform scale cancels out of a ratio. Multiply the top and the bottom by the same number and the drawn size is identical. 199 animations across roughly 70 units were shrunk, 2,776 images were resized, and their compressed siblings were regenerated at the new size, taking one goblin bomb lobber attack sheet from 287 KB to 140 KB.

The recorded content extents exist because of an earlier pass, which measured the true drawn bounding box of every unit in every animation so that the renderer sizes a sprite by the actual pixels of the model, its rider, its vehicle and any animation overshoot, rather than by the model's nominal box. It ignores anything fainter than a threshold rather than counting any non-zero pixel, because compression round-trips leave stray traces of almost nothing around a silhouette, and counting those would measure the noise instead of the art.
What a ceiling buys that a diet does not
The folder today measures 3.83 GB, which is larger than the 2.6 GB the shrink produced, because more units have been rendered since. That costs nothing, because the ceiling bounds what is resident rather than what exists: it does not care how much art the library holds, only how much is being drawn.
Two later changes moved the same numbers again. Players in 2D mode stopped downloading the 3D models at all, and the preload stopped holding between 320 MB and 880 MB in both modes. Both were found the same way this was, by counting resident bytes rather than transferred ones, which is the habit the phone crash bought.
The rule that transfers is short. Compression is a property of the file and residency is a property of the pixels, so a compression change can never be the answer to an out-of-memory crash. Count decoded bytes, cap them against something the device tells you, evict on draw rather than on load, and the size of your art library stops being a variable in the crash report. The same guessing about what a device can handle comes up again wherever a quality decision has to be made before anything has been drawn, and the answer there is the same: pick a default that survives rather than one that flatters.





