One Chrome flag took offscreen capture from 2.5 minutes per frame to 38-60 fps, same code, same machine
iOS kills the WebKit GPU process without firing webglcontextlost, so a draw-call floor of 10 over 120 frames is the detector that actually works
A price rendered before StoreKit answers is a screen that cannot charge, so the bridge only exists once the catalog returns
The world folded every 3000 m while the city repeated every 540 m, and 3000 % 540 = 300 walked the whole city off the road
I shipped a Three.js game to the iOS App Store. It runs in a WKWebView through Capacitor, it makes zero network calls, and it went from first commit to approved in 17 days across 247 commits. Four things nearly sank it, and none of them were the game.
All four failed silently. Nothing threw, nothing logged, and every one of them looked like something else for at least a day.
One flag decides whether you can capture anything
I needed portrait video and App Store screenshots rendered offscreen, headless, reproducibly. The first pass ran at roughly two and a half minutes per frame. At 30 fps that is a little over two hours of compute for one second of footage, which is not a pipeline, it is a reason to give up and film the screen with a phone.
The cause was one argument. Headless Chrome was launching with --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader. SwiftShader is a software rasterizer. It is the right default for a CI box with no GPU and completely wrong for a machine that has one.
Switching to --use-angle=metal --enable-gpu rendered the same scene at 38 to 60 fps. Same code, same machine, same browser, one flag.
Two things worth taking from this. First, the failure mode of a software renderer is not an error, it is patience, so you can lose days assuming the scene is too heavy and optimizing geometry that was never the problem. Second, both configurations still live in my tools directory on purpose, because the SwiftShader one is deterministic and the Metal one is fast, and those are different jobs. The trap is only that the slow one was the default.
If you are rendering WebGL offscreen and it feels impossibly slow, check which backend you actually got before you touch a single mesh. I covered the same class of problem from the rendering side in I Rebuilt a Solar Eclipse in WebGL, where the thing that looked wrong on screen was never the thing that was wrong in the code.
The GPU process dies and nothing tells you
iOS kills WKWebView's GPU process whenever it decides memory is tight. This is not a crash. The app stays up. JavaScript keeps running. The game loop keeps ticking, the score keeps counting, the HUD stays perfectly alive, and the world is black.
The obvious handler is the first line of defence:
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
recoverFromDeadRender('event-contextlost');
});
It is necessary and it is not sufficient. Observed on device: world gone, HUD alive, no event dispatched at all. WebKit does not reliably fire it.
So the shipped build carries two more detectors that do not depend on being told:
isContextLost()polled roughly every two seconds. Cheap, and it catches the silent loss the event missed.A draw-call floor. A composed frame of this game never submits fewer than about ten draws, because the post-processing chain alone is more than that. So 120 consecutive frames under ten draws, with the app well past boot, means the pipeline is dead even when the context object still insists it is fine.
All three paths trip the same recovery: bank the run so the player does not lose their score, write a marker, reload.
The part I would have skipped and should not have is the loop breaker. On a memory-starved device, a reload replays the single heaviest thing the app does, which is parse, scene build and shader warm-up. That can trigger the same GPU kill again. An unbounded recovery turns into boot, world, black, boot, forever, which is a hang with extra steps. Three recoveries inside ten minutes stops the cycle.
The marker persists through the reload and is readable off the device container, so a field incident leaves evidence instead of somebody's memory of it. That habit came out of the same thinking as The Error Log I Read Every Morning: if a failure cannot describe itself afterwards, you will be debugging a story rather than a bug.
A price on screen the app cannot charge
The game has six one-off in-app purchases and no ads in 1.0. StoreKit 2 talks to the web layer through a native bridge.
The obvious design is to install the bridge at startup and let the store screen ask it for prices. That design has a rejection built into it.
Product.products(for:) can legitimately return nothing. No signed-in Apple Account. No App Store Connect records yet. No StoreKit configuration file selected in the scheme. A dead network on first launch. In every one of those cases a bridge that already exists will happily hand the UI a price list it assembled from local constants, and now there is a screen showing a price the app cannot actually charge.
So the bridge is not installed until StoreKit has answered. The view controller waits for a reachable flag, and that flag only flips once Product.products(for:) comes back with at least one real product. The web layer's entire test for whether purchasing exists is whether the bridge object is there:
get available() { return !!this.bridge }
When StoreKit is unreachable the bridge never appears, and the store paints an honest unavailable state instead of a catalogue it cannot honour.
The general rule I would give anyone wiring payments through a bridge: make the capability's existence the same object as the capability's readiness. If those are two separate things, some screen somewhere will render the first without the second, and that screen is the one review opens.
3000 is not a multiple of 540
This one cost two days and is my favourite.
Runs are endless, so the world folds its origin back toward zero periodically to stop floating point coordinates going soft. That part worked fine.
What did not work: after long sessions the entire city and all the traffic vanished. Road alive, car alive, HUD alive, buildings gone. It never reproduced in a short test, which is exactly why it survived so long.
The numbers:
The fold quantum was 3000 m.
The city is a repeating ring of 9 chunks at 60 m each, so it repeats every 540 m.
3000 % 540 = 300.
Every fold slid the city 300 m sideways relative to the road. Once, twice, nine times. The buildings were still being drawn the entire time, just further and further off to the side, until they crossed the distance fade in the shader and got clipped out of existence.
Nothing threw. Nothing logged. The city was simply somewhere else.
The fix is to make the fold quantum commensurate with the ring. 2700 is exactly five city rings, so a fold moves a whole number of city periods and the world lands precisely on itself. I also had to fold n quanta at once for the catch-up case, because one subtraction is not enough if the player covered several quanta since the last check.
The general lesson: if you fold or wrap a world, every repeating structure inside it needs a period that divides the fold exactly, not approximately. Anything repeating on a different beat walks away from you at a constant rate, and a distance fade will hide the evidence right up until somebody plays for 40 minutes.
Two things made it findable. An on-screen probe printing the fold count and the current city offset. And finally noticing that "invisible" and "absent" are different failure modes worth testing separately. I had spent two days looking for the wrong one.
Bottom Line
Four bugs, one shape. Each was a measurement that was perfectly correct and answered a different question from the one I was asking. The renderer was not slow, it was the wrong renderer. The context was not lost, the pipeline was. The catalogue was not empty, it had never been asked. The city was not missing, it was 300 m to the left.
Every one of them was found by making the system say something about itself, on screen or in a marker file, rather than by reasoning harder about the code. The draw-call floor exists because a counter reporting a healthy frame while the screen is black is the whole problem in one line.
If you are shipping WebGL to iOS, the short version: check your ANGLE backend before optimizing anything, do not trust webglcontextlost alone, never let a capability exist before it is ready, and make every period inside a folded world divide the fold.
More build writeups from the same corner of the workshop are collected in the Lab Overview.
Top comments (0)