How We Took a PixiJS Map From 33.9 s to 3.0 s on a Slow Phone
Our PixiJS city map sat on its loading screen for 33.9 s on a 1.6 Mbps phone. What we took off the critical path, and the GPU memory crashes we hit on the way.

Our team builds DENSHIN, a game whose main screen is a live city map drawn with PixiJS v8: a 10 × 10 grid of city blocks, road markings, traffic, planes and other players moving in real time. Players complete on-chain quests to open parts of the city, and none of that matters for what follows.
On 29 July we loaded a production build with the network throttled to 1.6 Mbps and the CPU slowed 6x. The loading overlay said RENDERING MAP for 33.9 seconds. By the evening it said so for 3.0 seconds, with the same city behind it. This post covers what we moved, in what order, and the phone crashes from earlier in the summer that shaped the loader.
| Production build, 1.6 Mbps, CPU 6x | Before | After |
|---|---|---|
| "RENDERING MAP" on screen | 33.9 s | 3.0 s |
| Total time until the map is up | 38.7 s | 7.8 s |
| Bytes before the first frame | 6.9 MB | 1.0 MB |
The first frame waited for 196 MB of pictures#
Earlier that day, against production, the map took 35.7 s to appear and pulled 203.7 MB in 309 requests. 196.1 MB of it was 62 PNGs: the art painted into each block. All 62 sat inside the Assets.load() that GameApp.init() awaits before the overlay lifts. On home Wi-Fi that was about a minute.
Two fixes:
- Encode for the size you draw. A block is 18 grid cells of 64 px, so 1152 world pixels, and the camera never zooms past 1:1. The masters were PNGs of up to 1254 px, and not one pixel in any of them was transparent. Re-encoded as WebP, capped at 1152 px and with no alpha channel, the set is 16.6 MB, 11.8x smaller. Phones get a 512 px set of 4.6 MB.
- Take them out of the awaited list. The category is registered with
Assetsbut not enrolled in the bulk load:
// null: registered, but not in the list loadAll() awaits
this.registerGlob(
lowPower ? buildingMobileUrlMap : buildingUrlMap,
ASSET_CATEGORY.BUILDING,
null,
lowPower ? undefined : { autoGenerateMipmaps: true },
)
Every block is now drawn at once from one shared 494-byte blurred placeholder, and the real images stream in over it. The first frame dropped to 7.4 MB and 223 requests, with no block art at all.
The rest of the awaited list was decoration too#
On a 1.6 Mbps link, the 6.9 MB still behind the overlay is about 34 seconds of pure transfer. The render pipeline took 410 ms of the total, even at 6x CPU. We were bandwidth-bound, so the fix was to ask for fewer bytes, not to render faster.
Here is what the first frame was waiting for:
| What | Size | Needed at first paint? |
|---|---|---|
| Road markings and overlays | 324 KB | Yes |
| A rectangular block set | 984 KB | No. Its only renderer was switched off |
| Cracks, stains, litter | 2.7 MB | No. Phones never draw them |
| Cars, planes, manhole covers | 2.4 MB | Can arrive a second later |
Two rows need a note. Phones downloaded 2.7 MB of debris for renderers that are disabled on phones. And the rectangular set was pulled in by a glob for a code path behind a disabled flag. That one is a Vite trap: an eager import.meta.glob is rewritten into real imports before dead-code elimination runs, so its files ship whether or not anything can reach them. The same pattern had put 337 files, 70 MB, of another unused block set into dist. A build flag can't fix that. The glob has to go.
The manhole covers were a smaller case of the same thing: 1.32 MB of roughly 1000 px art for sprites drawn at 6–9 px. We deleted them.
The awaited list is now two placeholders plus the markings and road overlays, 324 KB in total. That's what took RENDERING MAP from 33.9 s to 3.0 s.
Four waves that never overlap#
Moving everything to "later" created a new problem: everything arrived later at the same time. The block art (about 17 MB on desktop, 4.6 MB on a phone) started downloading together with 1.1 MB of cars and planes and took their bandwidth. At 1.6 Mbps, traffic finished at 21.8 s. Until then players looked at empty streets and blurred blocks, and they read the blur as locked content.
Now the boot runs in four waves, and each one starts only after the previous one has landed:
1. loadAll() 324 KB -> the city is drawn
2. loadDeferred() 1.1 MB -> cars, planes, players
3. startStreaming() block art, nearest first
4. loadTrim() 2.7 MB -> asphalt debris (desktop)
With the same throttling, traffic now finishes at 11.7 s instead of 21.8 s, and the first wave is untouched. The chain lives in one method, and init() does not await it:
private populate(pipeline: RenderPipeline): void {
this.populated = new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
AssetLoader.loadDeferred()
.then(() => {
if (!this.alive()) return
const late = pipeline.renderDeferredLayers()
this.trafficSystem = late.trafficSystem
// ...quests, other players, planes
})
.catch(() => undefined)
.then(() => this.streamArt())
.then(() => this.trim(pipeline))
.catch(() => undefined)
.finally(resolve)
}))
})
}
Three details in there each came from a bug:
- Two animation frames, not one. One frame only queues the paint. The second makes sure the city was actually presented before the main thread gets busy again.
alive()checks. A wave can land after the player has left the page.- A 45-second deadline on the art wave. A block that never arrives must not hold back the wave behind it. Failures are swallowed, because every wave only adds to the city. A city without planes still works.
Nearest the player first#
The streamer used to walk blocks in city order. The top-left corner sharpened first while the block under the player stayed a blur. Now every scan (one per 140 ms) sorts pending blocks by distance to the centre of the screen, which is where the opening camera puts the player:
const candidates = this.pending
.filter((item) => item.state === 'idle')
.filter((item) => !viewportOnly
|| isInViewport(bounds, item.target.cx, item.target.cy))
.sort((a, b) => distanceSq(a, centre) - distanceSq(b, centre))
Sorting about a hundred items that often costs nothing next to one texture upload, and after a pan it follows the camera without any extra bookkeeping. At most 4 loads run at once on desktop and 2 on phones, because more than that makes decode and upload stutter the frame. After 2.5 s the viewport filter drops and the rest of the city fills in, outward from the centre.
Crash: Assets.unload and the null alphaMode#
That streamer only loads. It never unloads, and that's a deliberate rule.
In June an earlier block streamer freed GPU memory by calling Assets.unload() on blocks that left the screen. unload destroys the texture's GPU source. When the player panned back, Assets.load() for the same alias handed back the same cached texture object, which now had no source. A live sprite still held it, and the batcher crashed with Cannot read properties of null (reading 'alphaMode'). It never reproduced on localhost, because it needed real latency and memory pressure. Running load and unload one at a time didn't help: the cache reuse itself was the trap.
We removed eviction entirely. Memory is now bounded by texture size and count: one district, a fixed set of images, and smaller files on phones. The loader also refuses to hand out a dead texture:
const source = texture?.source
if (!source || source.destroyed) return undefined
Crash: a world-sized RenderTexture is 0.8 GB#
That same June day, phones killed the tab right after RENDERING MAP. There was no JavaScript error, just iOS WebKit running out of memory. Three decorative renderers (neon reflections, road debris, neon glow) each baked into a RenderTexture the size of the whole world, about 14848 × 14848 px. At 4 bytes a pixel that's roughly 0.8 GB of GPU memory each, and about 2.5 GB for all three. A desktop GPU absorbs that. A phone can't.
// Each bakes a world-sized (~14848²) RenderTexture, ~0.8 GB apiece.
const heavyEffects = !isLowPowerDevice()
if (heavyEffects) {
this.renderNeonReflections()
this.renderNeonGlow()
}
On phones the block surface that desktop bakes is drawn as live masked sprites instead. Our rule now: before any render target ships, multiply width × height × 4.
The device tier answers a memory question, nothing else#
The tier check itself is small:
export function isLowPowerDevice(): boolean {
const nav: Navigator & { deviceMemory?: number } = navigator
const mem = typeof nav.deviceMemory === 'number'
? nav.deviceMemory : undefined
const coarse = matchMedia('(pointer: coarse)').matches
const minSide = Math.min(screen.width, screen.height)
return (mem !== undefined && mem <= 4) || (coarse && minSide < 820)
}
Every phone trips it, fast ones included. That's fine for what it decides: 512 px block art instead of 1152 px, no mipmaps, a lower zoom cap to match, and no world-sized bakes.
Twice we used it for more than that. First we turned headlight beams off on phones, and later the whole locked-block treatment (haze and blur). Both times the phone quietly lost something the desktop had, and both times it was reported as a bug. Now the tier can make the pictures lighter, but it can't change what the map shows.
Frame time is a separate budget and gets its own answer. We measured the beams under mobile emulation, interleaving the runs because the test rig drifted by up to 2x between batches. At 4x CPU the p95 frame went from 18.5 ms to about 32 ms with beams on. At 1x there was no cost at all. We kept the beams.
The locked-block effects step down only when the median frame stays above 24 ms for 3 seconds. The first version triggered on p95 above 14 ms. A healthy session runs at p50 16.7 ms and p95 17.6 ms, so that ladder fired in every session: by second 26, only a flat fill was left. The median tells you a device can't keep up. The tail only tells you that frames sometimes spike.
Eight canvases on one landing page#
Our landing page showed eight live fragments of the map, which meant eight WebGL contexts. On an emulated iPhone 13, one scroll down and back up created 51 contexts and took the JS heap to 254 MB, which is where iOS starts killing tabs. Now only the hero is live. The other seven are stills baked in a headless browser by the same renderer from the same seed. The only visible difference is that the cars have stopped. The same scroll now creates 3 contexts.
Bugs that looked like slow loading#
- A late mipmap flag. We set
autoGenerateMipmapson a texture afterAssets.loadresolved, which forced a full re-upload of a live 1152² texture. That upload didn't fit in one frame, so blocks grew in from the top-left corner. Upload options now go in with the source, inAssets.add({ alias, src, data }). - A clipped blur. While panning, locked blocks drew half-empty and healed after about 10 seconds, which looked exactly like slow streaming. The real cause was a blur filter at 0.5 resolution with no
filterArea: PixiJS took the region from the object's bounds and cut off the bottom fifth. An explicitfilterAreafixed it. We found it by comparing sprite bounds in a live dev session and switching mask, overlay and blur off one at a time.
What we check now#
- The awaited load holds only what the first frame can't be drawn without. Every byte added there is paid by every player on every visit.
- We measure on a production build with network and CPU throttled. Localhost hides both the bandwidth math and the GPU memory race.
- Waves are ordered by what the player notices, and they never overlap.
- Textures stay resident. Memory is bounded by size and count, not by eviction.
- Every render target gets its width × height × 4 before it ships.
- The device tier picks asset sizes. Measured frame time decides which effects run.
The map is live at app.denshin.io and opens for guests with no sign-in. It's a good test case for DevTools throttling.


