obOB STUDIO
← Back to blog
Web Tech

WebGL Context Lost on iOS Safari: How to Recover Instead of Reloading

7 min readolivers-racers

You can't stop iOS Safari taking your WebGL context. It reclaims GPU memory from heavy tabs by design. What you can control is what happens next. Listen for webglcontextlost, call preventDefault(), reload for the player instead of asking them to, and cap the retries so nobody gets stuck in a boot loop. Then prove it works by forcing the loss with the WEBGL_lose_context extension, because you'll never reproduce it on demand on a real phone.

"it keeps saying context lost"

iPhone, Safari, my racing game. Every few minutes the whole thing would stop and show a message telling them to reload the page. They'd reload, play for a bit, and then it'd happen again. Very patient of them, honestly. I'd have closed the tab.

My first guess was that I'd leaked something, because my first guess is usually that I've leaked something. You allocate textures in a loop, memory creeps up, eventually the browser gets fed up and takes the toys away. Extremely my kind of bug. So off I went, hunting for the leak.

There is no leak. I want to be clear that I spent a genuinely embarrassing amount of time before accepting that, mostly because "it's not your fault" is a conclusion I don't trust when I'm the one who wrote the thing.

why iOS Safari loses the WebGL context

iOS Safari reclaims the WebGL context when a tab gets memory-heavy. Not "crashes." Reclaims. The OS wants the GPU memory back, Safari is holding a lot of it on behalf of your tab, so it takes it. A full 3D game running in a browser tab is about the most memory-heavy thing a page can be, so my game is a prime candidate every single time.

You can go and read the trail on this. It's a known thing across every engine. There are threads on it in the Babylon.js forum, the PlayCanvas forum, Unity's, and WebKit bugs going back to iOS 17. Some of those early ones are actually resolved: the big iOS 17 report was closed as a duplicate and fixed in Safari 17.1. But new ones keep getting filed against iOS 18, and the underlying behaviour hasn't gone anywhere, because at bottom this isn't a bug to be fixed so much as a device with finite memory making a decision.

So the question stops being "why is this happening" and becomes "what should happen when it does."

why Godot can't restore a lost context

Here's the awkward bit. When a context is lost, the browser hands your canvas back empty and every GL resource you had (shaders, textures, buffers) is gone. To recover you have to recompile and re-upload all of it.

Godot can't do that. There's no rebuild path for a lost GL context, so the export template does the only honest thing available to it and shows you a message saying the context was lost, please reload the page.

Specifically, it calls alert(). Which is true, and completely useless to a player on a phone. Nobody sitting on a bus knows what a context is, and nobody should have to. All they see is a game that broke and then asked them to go and fix it. That's a bit like a waiter bringing you the wrong meal and handing you an apron.

reload it for them

The player doesn't need to know any of this happened. My game saves money, garage and progress continuously, so a reload restores you to roughly where you were. The reload is the recovery. It just shouldn't be the player's job.

So the shell page listens for the loss itself:

canvas.addEventListener('webglcontextlost', function (e) {
    // preventDefault keeps the door open for recovery
    // instead of a permanent loss.
    e.preventDefault();
    if (document.getElementById('ob-recover')) { return; }
    ...
    overlay('<div>RECONNECTING…</div>'
        + '<div>Your progress is saved.</div>');
    setTimeout(function () { window.location.reload(); }, 500);
}, false);

The preventDefault() is what the spec wants if you ever intend to restore the context in place. Without it the browser treats the loss as final and won't consider restoring at all.

I should be honest that in my implementation it's belt-and-braces rather than load-bearing. Godot's own handler already calls it on the same event, I have no webglcontextrestored path anywhere, and my actual recovery is a page reload, which mints a fresh context regardless. Keep the line if you're writing a real in-place restore. Don't imagine, as I briefly did, that it's the thing saving you.

The overlay is branded and says two things: reconnecting, and your progress is saved. That second line is the whole point. A player who believes they just lost their cars is a player who closes the tab.

One thing I got wrong here, and only found when someone went looking properly. My overlay does not replace Godot's message. It sits on top of it. Godot's listener is attached to the same canvas and still fires, so the player gets the native alert() box as well, and because alert() blocks, it also stalls my 500 ms reload until they tap OK. preventDefault() doesn't stop other listeners on the same element; stopImmediatePropagation() would, but only if my handler runs first, which it doesn't.

Worse, my own test never caught it. The harness below drives a headless browser, and headless browsers auto-dismiss dialogs unless you explicitly listen for them. So the alert fired on every run and my script sailed past it, reporting success. A test that cannot see the thing it's meant to catch will pass forever. That one's going on a sticky note.

the part that stops it becoming a reload storm

Now, the obvious failure mode, which I'd like credit for spotting before shipping rather than after. If a device genuinely cannot hold the context (old phone, twelve other tabs, someone's got four hundred photos open in another window) then reloading loses the context again, which reloads, which loses it again. Congratulations, you've built a very polite infinite loop and locked a stranger inside it.

So the handler counts. It keeps a list of recent loss timestamps in sessionStorage and only auto-reloads while there have been three or fewer in the last 90 seconds:

var KEY = 'ob_gl_reloads';
function recent() {
    var now = Date.now(), h = [];
    try { h = JSON.parse(sessionStorage.getItem(KEY) || '[]'); } catch (e) {}
    return h.filter(function (t) { return now - t < 90000; });   // last 90s
}

Past that it stops trying and shows a different screen: your browser ran low on memory, closing other tabs helps, your money and cars are all saved, and a button to reload by hand. Three strikes and control goes back to the player.

sessionStorage rather than localStorage on purpose. The count should die with the tab. Someone who comes back tomorrow shouldn't inherit yesterday's bad afternoon.

how to test context loss with WEBGL_lose_context

This is the bit I couldn't find written down anywhere, and it's the bit that actually matters. You can write every line above, feel very pleased with yourself, and have absolutely no idea whether any of it runs. Because you can't make iOS Safari drop a context on demand. That's the browser's call, made under memory pressure you don't control, usually while you're not looking.

But WebGL ships an extension whose entire job is to fake it: WEBGL_lose_context. So I drove a headless browser to the game, waited for the engine to actually boot, and pulled the context out from under it:

const fired = await p.evaluate(() => {
  const c = document.querySelector("canvas");
  const gl = c.getContext("webgl2") || c.getContext("webgl");
  const ext = gl && gl.getExtension("WEBGL_lose_context");
  if (!ext) return "no-ext";
  ext.loseContext();
  return "lost";
});

Then check the overlay appeared with the right words before the reload takes the page away. The handler waits 500 ms, so you have to look fast:

await p.waitForTimeout(250);
const state = await p.evaluate(() => {
  const o = document.getElementById("ob-recover");
  return { present: !!o, text: o ? o.innerText.slice(0, 80) : null };
});

Overlay present, right text, canvas back after the reload. That's the whole test. It takes a minute or two end to end (most of that is waiting for a 39 MB engine to download and boot before you can knock it over) and it's the only reason I believe any of this works, because I have never once managed to reproduce the original bug on a real phone when I actually wanted it to happen. Bugs are like that. They're shy in front of an audience.

Add page.on('dialog', ...) while you're in there. Mine didn't have one, which is exactly how it missed the alert box described above.

Two things that will trip you up if you try this: wait for the engine to be genuinely running rather than for the page to have loaded, because losing a context that was never fully set up proves nothing. And a headless browser on a software renderer needs the right flags before it'll give you a WebGL context at all.

and then make it rarer

Recovering gracefully is treating the symptom. The cause is memory pressure, so the other half of the fix was giving iOS less to want back. The web build already ran leaner than the native app; I pushed the mobile-web branch further:

# Mobile web trimmed harder (0.38 / 0.60): iOS Safari reclaims the WebGL
# context under memory pressure, and scenery meshes plus the 3D render
# target are the two largest GPU allocations I control.
_prop_scale = 0.38 if mobile_web else 0.6
...
vp.scaling_3d_scale = 0.64 if mobile_web else 0.85

Scenery density from 0.45 down to 0.38, and the 3D render target from 0.72 of screen resolution down to 0.64. On a phone at arm's length you don't notice either. The GPU notices both, because a render target is quadratic. Dropping the scale from 0.72 to 0.64 cuts about a fifth off that buffer.

Both are inside a mobile-web-only branch. Desktop web keeps its settings, the native Android build is untouched, and nothing about this needed an APK rebuild or a version bump.

Worth saying that runtime memory is only half the story on a browser game. The same build hands the phone a 37 MB WebAssembly engine before a single frame is drawn. I went and measured what a Godot web export actually ships, and the split was not what I expected.

ob

Written by Oliver

I build browser games and simulations on my own, everything here runs in a tab, with no installer and no account. The biggest is Oliver's Racers: procedural circuits in Godot 4, online multiplayer relayed by a Raspberry Pi in my room, and an Android build. Almost nothing here is imported artwork; the cars, trees and grandstands are built out of boxes and cylinders in code at load time.

More about me · See the projects