Labs · Experiment 5 of 7 · Performance, The game
Where a web game's memory goes on iOS
- Started
- Updated
- Status
- concluded
- Verdict
- Mixed
Our game said it used about 340 MB; the iPad said the page used 1.45 GiB. Where does the other billion bytes go, and what should a web game measure instead?
Why
Safari on iPhone and iPad kills a page that uses too much memory, without warning. Our game reports its own memory use and kept a phone budget of 450 MB against that figure. Yet on a physical iPad, about 30 s after Start downtown, Instruments showed the page's process (WebContent) at 1.45 GiB of physical footprint while the game's own line said 313–367 MB. Phones were crashing at numbers the budget said were safe.
Hypothesis
The game's memory figure isn't wrong about what it counts; it counts the wrong things:
- it sees only scene GPU data and the typed arrays behind it;
- the JavaScript heap, which it can't see, is the largest item;
- on the device, GPU memory made for the page is charged to the page's process too.
Method
- Read the meter: the game's memory report walks the scene and adds geometry buffers, instance data, textures (with mipmaps) and frame targets. JavaScript objects, typed arrays not attached to scene geometry, JIT code, WebKit's own allocations, shader programs and canvases are invisible to it.
- Measure by category: the iPad Pro 11-inch (M5) simulator, iOS 27.0, Mobile Safari, one tab, the game on a local server at the safe tier, downtown at midday. The simulator's processes are Mac processes, so macOS's
footprintandvmmapgive the same accounting iOS uses, minus the device's GPU arrangement. - Separate live data from garbage: trigger WebKit's low-memory handler (
notifyutil -p org.WebKit.lowMemory), which drops caches and JIT code and runs a full collection. - Calibrate the categories: a test page holding 1.26 GB of Float32Arrays; a test page uploading 300 MB of WebGL index buffers and dropping its arrays; a million records shaped like the game's in JavaScriptCore and in V8.
- Heap snapshot: the same start, about 80 s after the tab opened (footprint 742 MB at that moment). With no GUI available, a small client spoke WebKit's remote-inspector protocol to the simulator and asked the page for a garbage collection and then a heap snapshot: the same data Safari's Web Inspector shows under JavaScript Allocations. The snapshot is 176 MB of JSON: 2.34 million objects and 7.06 million references. Two scripts summarised it by type, by first referrer and by four-level retainer chains.
- Cost: none.
Results
1. The page's memory by category
| Category (footprint) | After start | +2 min | After low-memory | What it is |
|---|---|---|---|---|
| WebKit Malloc | 628 MB | 635 MB | 581 MB | WebKit's general allocator: JavaScriptCore's objects, code and strings, WebCore's allocations, and free space inside them |
| "WebAssembly Memory" | 105 MB | 105 MB | 105 MB | typed-array storage: the game loads no WebAssembly; the 1.26 GB Float32Array test showed 1,211 MB in exactly this category |
| Graphics (untagged) + raster data | 64 MB | 64 MB | 64 MB | canvases drawn for textures and labels, compositing |
| JIT code | 26 MB | 26 MB | 0 | freed under pressure, re-made on demand |
| Other | ~35 MB | ~35 MB | ~35 MB | |
| Total footprint | 860 MB (peak 1,051) | 866 MB | ~790 MB |
2. The heap snapshot: the JavaScript heap is 275 MB
About the size of Chrome's live heap on the same start (250–290 MB including typed arrays). It is not the 581 MB in WebKit Malloc. The largest types:
| Type | MB | Count | Held by |
|---|---|---|---|
| Object | 52.8 | 770k | 33 MB inside arrays: the road network's per-edge objects (about 17 MB together), building polygon rings, block cornices, colliders, park tree sites |
| Float32Array | 49.2 | 18k | 32.7 MB in 2,807 cut ground-tile records with no JavaScript referrer: something native keeps them (a pending storage result, a worker message or a structured clone; not identified) |
| Canvas 2D contexts | 34.3 | 45 | 41 contexts (32 MB) kept by their canvases after their textures were made |
| Map/Set storage | 25.0 | 39k | |
| Compiled code | 21.9 | 9k | |
| ArrayBuffer | 21.7 | 2.6k | typed storage with no JavaScript owner left on the path |
| Array | 13.9 | 912k | road network lanes and moves; matrices |
| AudioBuffer | 12.8 | 73 | synthesised engine, horn and other sounds, all made at the start |
| string | 7.5 | 130k |
3. So the largest item is native memory with no JavaScript owner
JavaScriptCore's live objects, code and strings account for about 200 MB of WebKit Malloc; typed-array storage (about 70 MB in the snapshot) sits in the separate "WebAssembly Memory" category. That leaves about 350–380 MB of WebKit Malloc with no live JavaScript owner.
Our leading explanation, which is an inference we haven't tested yet, is free space left inside JavaScriptCore's heap after the start's churn. About 6.7 GB is allocated during the start; the collector doesn't move objects, so survivors stay scattered across blocks the allocator can't hand back. That fits the low-memory test above: the number of live allocations nearly halved, but the bytes fell by only 13%. WebCore's own allocations make up the rest. The test is a build of the same city with about 0.9 GB less churn: if WebKit Malloc falls by much more than the objects removed, it's fragmentation.
One scale (1.56 GB = 1.45 GiB). The parts come from different measurements and runs and don't sum exactly; the JS heap figure includes typed arrays that the footprint counts in a separate category.
4. GPU memory, and why the device shows more (inference)
- No CPU copy of GPU buffers in the page: the 300 MB WebGL test left WebContent at 23 MB while WebKit's separate GPU process grew by about that much. With the game open, that GPU process was 747 MB, leaving about 450 MB of GPU data for the game (the game's own figure: about 340 MB).
- On the physical iPad, WebKit's GPU process was only 235 MiB, yet the game's GPU data alone is 340–450 MB. The numbers add up only if GPU memory made for the page is charged to WebContent, which is how we understand WebKit on iOS attributes Metal buffers and surfaces for memory-limit purposes (not verified here). About 790–860 MB of the page's own memory plus about 450 MB of GPU data plus compositing gives 1.25–1.35 GB, against 1.56 GB measured; the remaining ~0.2 GB would be device-only costs we didn't measure.
Limitations and threats to validity
- No physical-device breakdown. The device split is an inference from the simulator plus the earlier device run.
- The fragmentation explanation is untested. The 350–380 MB is measured as "no live JavaScript owner"; what it is remains an inference.
- The snapshot and the category table are different runs of the same start (742 MB and 860 MB footprints at the time).
- The simulator ran the game's plain source tree; the device ran the bundled release (same code, minified).
- The second snapshot left the simulator's inspector unresponsive, so WebKit's own memory tracking couldn't be read afterwards.
Verdict
Supported (1): the meter counts only scene GPU data and typed arrays.
Not supported (2): the JavaScript heap is 275 MB, not the largest item. The largest is 350–380 MB of native WebKit memory with no live JavaScript owner, most likely heap fragmentation from the start's churn (untested).
Inconclusive (3): the device numbers are consistent with GPU memory being charged to the page, but we haven't confirmed it on the device.
Practical application
- For the game: the 450 MB phone budget compared about a quarter of the footprint against a limit set for the whole. A change now in review (not yet shipped) makes the memory line end with an estimated page footprint on phone settings (the scene plus a provisional overhead measured once on the iPad) judged against a footprint budget, while still reporting the scene's own figure.
- For any web game or 3D web app on iOS: a scene-walking meter can't see most of the footprint, and neither can a JavaScript heap snapshot: a third or more can be allocator memory no object owns. Cutting churn during start-up (fewer short-lived objects) may matter as much as cutting what survives.
- Measuring from inside a native wrapper is harder than it looks: on a device, iOS refuses an app's request for its web-content process's footprint (permission denied; tested on the iPad), and the app's own footprint figures describe the app, not the page. On a device the page's footprint can be estimated from inside or measured from outside (Instruments).
- The biggest reductions, in order: (1) the start's churn, if the fragmentation test confirms it (up to ~350 MB); (2) release the 32.7 MB of cut ground-tile records held natively once each tile is on the GPU; (3) make the road network's per-edge objects typed; (4) release canvases once their textures are uploaded (32 MB); (5) create audio buffers on first use.
Reproducibility
footprint <WebContent pid> # category totals
vmmap --summary <WebContent pid> # region detail
xcrun simctl spawn booted notifyutil -p org.WebKit.lowMemory
The heap snapshot client (rwi_snapshot.py, WebKit remote inspector: Heap.gc then Heap.snapshot) and the analysers (heapsum.py, heapdeep.py) are in the Slopcouver repository, private for now, with the game's memory meter (app/js/memory-report.js). The calibration pages are small HTML files that allocate typed arrays or WebGL buffers of a known size.
Source of every figure: the project's WebContent memory study of 8 Nov 2026 (category tables, calibration runs, heap snapshot summaries) and the earlier physical-iPad Instruments run.