Handling WebGL context loss in web games
What happens when the browser drops your WebGL context, how to opt in to restoration, what to rebuild, how to simulate a loss, and what Phaser 3.90 does.
A WebGL context can disappear while your game is running. When that happens, every texture, buffer and shader program the game created is gone, and the canvas stops drawing until the context comes back, if it does. This guide covers the two events involved, the one call that decides whether the context is restored, what you rebuild afterwards, and how to trigger a loss on purpose so you can test the path before a portal’s QA finds it.
Why contexts get lost
MDN’s isContextLost() page lists several causes:
- Several pages together ask too much of the GPU, so the browser drops their contexts and restores only one.
- The system switches between GPUs, for example on a laptop with an integrated and a discrete GPU.
- Another page runs a GPU operation that takes so long the browser resets the GPU, which loses every WebGL context in the browser.
- The user updates the graphics driver while the browser is running.
The WebGL specification adds that implementations use the lost and restored events to regulate power and memory use. A portal game shares the browser with the portal page, its ads and the player’s other tabs, so it does not control any of these causes.
The two events and the call that matters
The browser fires webglcontextlost at the canvas when the drawing buffer is lost, and webglcontextrestored when it gets one back. Neither event bubbles, so listen on the canvas itself.
The specification’s steps for the lost event are short:
- Set the context’s lost flag and mark every WebGL object created by the context as invalid.
- Disable every extension except
WEBGL_lose_context. - Fire
webglcontextlost. If the event was not canceled, stop here. The context stays lost. - Otherwise wait until a drawing buffer can be restored, then restore it and fire
webglcontextrestored.
So event.preventDefault() in the lost handler is what opts you in to restoration. Without it, the context never comes back.
Plain Canvas 2D works the other way round. For its contextlost event, MDN says the browser tries to restore the context by default, and calling preventDefault() stops it from doing so. MDN also marks that event as not Baseline, so it does not fire in some major browsers.
What is gone after a restore
The specification says resources such as textures and buffers created before the loss are no longer valid after the restore, and previously enabled extensions are not restored. The context object stays the same: the browser attaches a new drawing buffer to it. In practice, rebuild:
| Gone | Rebuild from |
|---|---|
| Shaders and programs | Your shader source strings. Set uniforms again, since they belong to the program. |
| Buffers, vertex array objects | Your vertex and index arrays, kept in JavaScript |
| Textures, framebuffers, renderbuffers | Decoded images kept on the CPU side, or a fresh fetch |
| Extensions | Call gl.getExtension() again |
| GL state (blend mode, viewport, bound objects, pixel store flags) | Your setup code |
The gl object, the canvas and all your JavaScript game state survive.
While the context is lost, WebGL calls do not throw. Per the specification, most methods skip their work and return a default value, which is null for nullable return types, and an object created during the loss is invalid from the start. Guard any of your own code that throws when a query returns null. MDN’s WebGL best practices note that a well-formed page should only ever see two errors, and context loss is one of them.
A small handler module
This module splits the job in two. watchContextLoss() owns the events. createGpuResources() keeps a factory for each GPU object, so the code that creates everything at startup is the same code that rebuilds it after a restore.
// file: examples/snippets/webgl-context-loss/context-loss.js
// context-loss.js: survive webglcontextlost / webglcontextrestored in a hand-written WebGL game.
/**
* Registry of GPU objects (programs, buffers, textures, extensions) that can be rebuilt from
* CPU-side data. Factories run in definition order, so a later one can use an earlier one via get().
*/
export function createGpuResources() {
const factories = new Map();
let live = new Map();
const get = (name) => {
if (!live.has(name)) throw new Error(`GPU resource "${name}" is not available (context lost?)`);
return live.get(name);
};
return {
define(name, create) {
if (factories.has(name)) throw new Error(`GPU resource "${name}" is defined twice`);
factories.set(name, create);
},
createAll(gl) {
live = new Map();
for (const [name, create] of factories) live.set(name, create(gl, get));
return live.size;
},
get,
// After a loss every handle is already invalid: drop the references, do not call gl.delete*().
forget() {
live = new Map();
},
get size() {
return live.size;
},
};
}
/** Listens on the canvas (or any EventTarget) and opts in to restoration with preventDefault(). */
export function watchContextLoss(canvas, { onLost, onRestored }) {
let lost = false;
const handleLost = (event) => {
event.preventDefault(); // without this the browser does not try to restore the context
if (lost) return;
lost = true;
onLost(event);
};
const handleRestored = (event) => {
if (!lost) return;
lost = false;
onRestored(event);
};
canvas.addEventListener('webglcontextlost', handleLost, false);
canvas.addEventListener('webglcontextrestored', handleRestored, false);
return {
isLost: () => lost,
stop() {
canvas.removeEventListener('webglcontextlost', handleLost, false);
canvas.removeEventListener('webglcontextrestored', handleRestored, false);
},
};
}
/** Test helper: loses the context now and restores it later. Returns false if the extension is missing. */
export function simulateContextLoss(gl, restoreAfterMs = 2000, schedule = setTimeout) {
const ext = gl.getExtension('WEBGL_lose_context');
if (!ext) return false;
ext.loseContext();
// WEBGL_lose_context is the one extension that stays usable while the context is lost.
if (restoreAfterMs >= 0) schedule(() => ext.restoreContext(), restoreAfterMs);
return true;
}
The registry deliberately has no “delete” step. After a loss the handles are already invalid, and calls on a lost context skip their work anyway. Dropping the references is enough.
Because watchContextLoss() only needs addEventListener, its tests dispatch real Event objects at a plain EventTarget in Node. They check that the lost event is canceled, that duplicate events are ignored, and that resources are rebuilt in order.
Wiring it into a draw loop
A WebGL 2 page that draws one textured quad, with the loss path in place:
// file: examples/snippets/webgl-context-loss/textured-quad.js
// textured-quad.js: a WebGL 2 draw loop wired to context-loss.js.
// Open the page with ?lose-context to trigger a simulated loss and restore.
import { createGpuResources, simulateContextLoss, watchContextLoss } from './context-loss.js';
const VERTEX = `#version 300 es
in vec2 a_pos;
out vec2 v_uv;
void main() { v_uv = a_pos * 0.5 + 0.5; gl_Position = vec4(a_pos, 0.0, 1.0); }`;
const FRAGMENT = `#version 300 es
precision mediump float;
in vec2 v_uv;
uniform sampler2D u_tex;
out vec4 color;
void main() { color = texture(u_tex, v_uv); }`;
function compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS) && !gl.isContextLost()) {
throw new Error(gl.getShaderInfoLog(shader));
}
return shader;
}
async function start() {
const canvas = document.querySelector('canvas');
const notice = document.getElementById('gpu-notice');
const gl = canvas.getContext('webgl2');
// Keep the decoded image on the CPU side: the GPU copy disappears with the context.
const atlasImage = await createImageBitmap(await (await fetch('atlas.png')).blob());
const gpu = createGpuResources();
gpu.define('program', (gl) => {
const program = gl.createProgram();
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
gl.linkProgram(program);
return program;
});
gpu.define('quad', (gl) => {
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
return buffer;
});
gpu.define('atlas', (gl) => {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasImage);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
return texture;
});
let frame = 0;
function draw() {
const program = gpu.get('program');
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gpu.get('quad'));
const pos = gl.getAttribLocation(program, 'a_pos');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
gl.bindTexture(gl.TEXTURE_2D, gpu.get('atlas'));
gl.drawArrays(gl.TRIANGLES, 0, 6);
frame = requestAnimationFrame(draw);
}
watchContextLoss(canvas, {
onLost() {
cancelAnimationFrame(frame); // also the place to pause game logic and audio
gpu.forget();
notice.hidden = false;
},
onRestored() {
gpu.createAll(gl); // same gl object, new drawing buffer: set any GL state you rely on again
notice.hidden = true;
frame = requestAnimationFrame(draw);
},
});
gpu.createAll(gl);
frame = requestAnimationFrame(draw);
if (new URLSearchParams(location.search).has('lose-context')) {
setTimeout(() => simulateContextLoss(gl, 2000), 3000);
}
}
start();
Three details matter in a real game:
- Keep CPU copies of texture sources. If you call
close()on anImageBitmapafter uploading it to save memory, a restore has to fetch and decode the image again. Decide per asset which costs less for you: memory now or a re-download later. - Stop the loop on loss. Cancel
requestAnimationFrame, and pause the game clock and audio in the same place. If your portal SDK expectsgameplayStop()on interruptions, as Poki’s requirements do for pauses and menus, treat a context loss as one. - Tell the player. Restoration is not guaranteed to happen. A visible “Restoring graphics” notice beats a frozen frame. If the context was never created,
webglcontextcreationerrorcarries astatusMessageyou can log.
Simulating a loss
The WEBGL_lose_context extension exists for this purpose and works with WebGL 1 and WebGL 2. loseContext() runs the same lost steps as a real loss and keeps the context lost until restoreContext() is called. MDN notes that restoreContext() raises INVALID_OPERATION if the context was not lost. The context is usable again only after webglcontextrestored fires.
The example page runs simulateContextLoss() when the URL contains ?lose-context. From the DevTools console of any WebGL page, the same test is one line:
const ext = document.querySelector('canvas').getContext('webgl2').getExtension('WEBGL_lose_context');
ext.loseContext(); setTimeout(() => ext.restoreContext(), 2000);
getContext() returns the existing context when called again with the same type. Use 'webgl' if your game created a WebGL 1 context.
MDN’s best-practices page also suggests losing contexts on purpose with this extension once you no longer need a canvas, for example a temporary preview canvas. It does not recommend doing so on page unload.
What engines document
Phaser 3.90. The Phaser source documents both events. LOSE_WEBGL says the game keeps running while the context is lost, but no WebGL resources can be created. RESTORE_WEBGL fires after Phaser has recreated its resources. Its comment says most resources come back automatically, you redraw dynamic textures that were GPU-bound, and you wait for the event before recreating resources of your own. The WebGLRenderer default handler calls preventDefault() for you. setContextHandlers() (since 3.85) replaces the handlers, but your replacements must still call dispatchContextLost and dispatchContextRestored.
// Inside a Phaser 3.90 scene's create()
const renderer = this.game.renderer;
if (renderer.type === Phaser.WEBGL) {
renderer.on(Phaser.Renderer.Events.LOSE_WEBGL, () => this.scene.pause());
renderer.on(Phaser.Renderer.Events.RESTORE_WEBGL, () => {
redrawDynamicTextures(); // your RenderTexture / DynamicTexture drawing code
this.scene.resume();
});
}
Unity and Godot. The Unity 6.3 Web manual pages and the Godot web export pages checked for this site do not describe context-loss handling, so the engine behaviour is not covered here. Test it with WEBGL_lose_context from the console of your built game.
What to check before you submit
- The
webglcontextlosthandler callspreventDefault(), and the loss path has been triggered at least once withWEBGL_lose_context. - After a simulated restore, every texture, program, buffer and extension is back, and the frame looks the same as before.
- The game loop, game clock and audio pause during the loss, and the portal’s gameplay events stay consistent.
- Code that compiles shaders or reads parameters does not throw when the context is lost.
- A player-facing notice shows while the context is lost.
- In Phaser, your own render textures redraw on
RESTORE_WEBGL.
Limits of this guide
How often contexts are lost on portal traffic is not published by any of the sources here, and no loss rate was measured for this guide. The tests check the handler logic with Node’s EventTarget, not a real GPU. The textured-quad example is bundled by esbuild to catch syntax and import errors, but it was not run in a browser for this guide. The WebGL specification linked here is the Khronos editor’s draft dated 2026-06-30.
Questions
- Do I have to call preventDefault() on webglcontextlost?
- Yes, if you want the context back. In the WebGL specification's steps, the browser only tries to restore the drawing buffer when the webglcontextlost event was canceled.
- Does Phaser handle context loss for me?
- Mostly. In Phaser 3.90 the WebGL renderer cancels the event, recreates its own resources and then emits RESTORE_WEBGL. Its documentation says you still redraw GPU-bound dynamic textures and recreate your own WebGL resources after that event.
- How do I test context loss without crashing a GPU?
- Call loseContext() on the WEBGL_lose_context extension, then restoreContext() a moment later. MDN describes the extension as a way to simulate both events, and it works with WebGL 1 and WebGL 2 contexts.
Sources
- WebGL Specification 1.0, editor's draft of 2026-06-30: context lost and restored events (Khronos)
- HTMLCanvasElement: webglcontextlost event (MDN)
- HTMLCanvasElement: webglcontextrestored event (MDN)
- HTMLCanvasElement: webglcontextcreationerror event (MDN)
- WebGLRenderingContext: isContextLost() method (MDN)
- WEBGL_lose_context extension (MDN)
- WEBGL_lose_context: loseContext() method (MDN)
- WEBGL_lose_context: restoreContext() method (MDN)
- HTMLCanvasElement: contextlost event, Canvas 2D (MDN)
- HTMLCanvasElement: getContext() method (MDN)
- WebGL best practices (MDN)
- LOSE_WEBGL event source, Phaser v3.90.0 (phaserjs/phaser on GitHub)
- RESTORE_WEBGL event source, Phaser v3.90.0 (phaserjs/phaser on GitHub)
- WebGLRenderer source, Phaser v3.90.0 (phaserjs/phaser on GitHub)