The 300 MB limit that kills WebGPU pages on iPhone

· 5 min read · webgpu, ios, three.js, performance, debugging

A white sports car with orange flames from the exhaust on a dark, wet circuit at night

On some iPhones, Endurance Racing was lost at random: the page showed GPUDevice lost (destroyed), sometimes during the load and sometimes two seconds after the first frame. Switching off features did not isolate it. With every pass, the world, the menu and the audio off, it still happened.

I could have kept turning things off. Instead the phone itself told me what was wrong, and it was memory. This post is how I found it, what the number is, and what fixed it. It continues the earlier post on why a WebGPU device was destroyed on iPhone, which was about the count of shader modules.

Reading the phone's own crash logs #

An iPhone paired with a Mac will list its crash logs:

xcrun devicectl device info files --device <udid> --domain-type systemCrashLogs

Among them are JetsamEvent-*.ips files, JSON with every process's memory pages and the reason iOS killed something. On October 16, four of five reports had killed com.apple.WebKit.GPU for highwater, at 19,200 to 19,534 pages of 16 KB. That is 300 to 305 MB. So the limit is about 300 MB, and what I had been calling a lost device was iOS ending the page's GPU process.

There is no GPU crash report for it. The process is not crashing, it is being ended for memory. Four losses of my test battery left no jetsam file at all, so WebKit may end its own GPU process before iOS does.

What I asked for is not what it held #

I had already measured what the game asks the GPU for: about 148 MB. 35 MB of compressed textures, 57 MB of render targets and 56 MB of vertex and index buffers. I had even found my first count wrong, because I priced compressed textures as if they were RGBA8 and got 245 MB.

Neither number was the process's footprint. So I measured that, in real WebKit: a WKWebView on the Mac, with footprint run on its GPU process, using the phone profile, from a cold start. Three findings:

  1. Every GPUShaderModule costs about 1.1 MB while its JavaScript object lives. The module keeps its parsed WGSL and the data to make pipelines from it. The game's 182 modules were about 150 MB. And three keeps every module forever, although nothing reads it after the pipelines exist.
  2. One real full frame at the end of the warm-up makes the GPU process grow by about 125 MB for a few seconds. That is the Apple GPU's transient memory for a frame of the whole scene, drawn only to warm things that already exist.
  3. Pipelines being compiled at the same time change nothing. I had a suspect in compiling many at once, and I limited how many could be in flight. The footprint was 453 to 461 MB at every setting.

In a synthetic page with 80 modules, the footprint was 189 MB. When I dropped the references to the modules, 40 MB, and the pipelines made from them kept working.

The fix #

Two changes, both only on Apple phones:

  • After the warm-up has made the pipelines, the game drops its references to the shader modules. If a pipeline is made later, for a new render context or for the garage's cars, its module is made again from the program's code and dropped once the pipeline is on its way.
  • The warm-up no longer draws that last real full frame. The first frames of the render loop do the work under the loader.

The lap itself makes zero pipelines and zero modules, so nothing has to be brought back while you drive.

Cold start, phone profile, real WebKit Before After
Peak footprint of the GPU process 469 MB 274 MB
Steady footprint 241 MB 147 MB
Picture the same (mean difference 0.2 against a noise floor of 0.19)

In one set of runs, releasing the modules alone brought the steady state from 244 to 140 MB and the peak from 450 to 352 MB. Skipping the real frame took the peak down further.

Did it work on the phone? #

On an iPhone 13 with iOS 27.0.1, I launched the old build and the new one five times each, cold, with nothing else open. The new build held in 5 launches out of 5. The old one held in 3 of 5, and both losses were during the load.

Before this fix, my battery of experiments could not isolate anything. The default configuration held 3 of 3, compiling four at a time held 1 of 3, and a lighter one held 2 of 3. That is what a hard limit predicts, when what ends the page is whatever happens to be in flight at the moment you cross it.

The lab that made it possible #

Two tools were worth building.

  • A stability lab page served from the Mac over HTTPS, that I open on the phone. It has one row for every URL switch the game reads, a heartbeat that records how a launch ended (the reason for a loss, the stage, "still running", or "ended without a report" when the system reloaded the page), and repeated runs that discard the first, warm-up run.
  • A cold start that does not need to restart the browser. The losses showed up on the first load after Safari was force-quit, and hardly at all in repeated launches, because the operating system's shader cache was warm. ?cold=1 wraps createShaderModule and adds a constant nobody has compiled before to every module's output, so the cache has nothing to give. Identical programs still get identical text, so they still share a module, and a pixel moves by 1e-9.

There is also ?ballast=N, which adds N MB of idle textures on top of any configuration. The size at which a holding configuration starts to be lost is the memory margin of that phone's GPU process.

What is still open #

The WebContent process is at 962 to 1,405 MB on the phone, and a navigation in the same process keeps its memory, so a chain of launches leaves the phone short. And the first frames of the loop still add 100 to 150 MB for about 15 seconds. The fix put the measured peak under the limit, but not by much.

A related lesson, from the same hunt: on iPhone, Safari draws the page's DOM in the same GPU process as the WebGPU device. A blurred text-shadow animated per typed letter, a filter animation on a portrait and nine drop-shadow filters on a speedometer were enough to destroy the device. In a game on a canvas, the flat HUD is part of the performance budget.

Endurance Racing is free, and the project page is here.

Related projects