Error Tracking for Canvas and WebGL Apps: Catching Context-Lost Crashes and Shader Failures
Track WebGL context loss and GPU crashes before users see a black screen.
The support ticket came in at 2 AM: "Game freezes after loading the third level." Attached was a screenshot of a black canvas where the gameplay should be. No error message. No console output. Just... nothing.
Six hours. That's how long I spent chasing it. Checked the asset loader. Checked the game state machine. Checked the render loop. Everything looked fine — and I mean genuinely fine, not "fine until you actually test it" fine. Then I tested on a machine with 2GB VRAM instead of my 8GB workstation, and watched the WebGL context silently die the moment the third level's 4K texture atlas tried to upload. I felt like an idiot.
The context was lost. The browser never told me. The render loop kept calling draw methods on a dead context. And because I wasn't tracking WebGL errors — just JavaScript exceptions — my monitoring showed nothing. Users saw a black screen. My dashboard showed zero errors.
That's the problem with WebGL and Canvas apps. The GPU layer fails in ways that JavaScript error boundaries don't catch. Context loss. Shader compile failures. Out-of-memory. Texture format mismatches on specific GPUs. These aren't exceptions. They're silent failures that leave users staring at a frozen frame while your error dashboard claims everything's fine. If you're coming from traditional web development where error tracking correlates with funnel drop-offs, WebGL adds an entirely different failure mode.
This tutorial wires up JustAnalytics to catch all of it — context loss, shader errors, GL errors, and the GPU-specific crashes that only happen on that one Intel HD 4000 your user refuses to upgrade from.
What we're building
By the end of this, you'll have:
- WebGL context loss detection with recovery tracking
- Shader compile and program link error capture with source context
- GL error monitoring that catches silent failures mid-frame
- GPU and browser metadata attached to every error (because "works on my machine" isn't debugging)
- Enough context to reproduce the crash without asking the user to "try refreshing"
The whole setup is about 80 lines of code. Works with raw WebGL, Three.js, Babylon.js, PixiJS — the patterns translate.
Prerequisites
- A WebGL or Canvas-based application (games, data visualization, 3D product viewers, whatever)
- A JustAnalytics account — free tier gets you 100K events/month
- Basic familiarity with the WebGL rendering pipeline (if "shader" and "context" mean nothing to you, the MDN WebGL tutorial is worth thirty minutes first)
- Node.js 18+ if you're bundling, or just drop the tracking code inline for simpler setups
If you're starting fresh with JustAnalytics, see the Next.js 15 integration tutorial for the basic setup — same patterns, different context. For Django projects, check the Django middleware tutorial.
Step 1: Context loss detection
WebGL context loss happens when the GPU needs resources your app was using. Tab backgrounded. Driver crash. Memory pressure. System suspend. The browser kills your context, and every subsequent GL call fails silently or throws.
Here's the tracking setup:
// src/webgl-error-tracking.js
const JA_SITE_ID = "your-site-id";
function getGPUInfo(gl) {
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
if (!debugInfo) return { vendor: "unknown", renderer: "unknown" };
return {
vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL),
renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL),
};
}
function trackWebGLError(errorType, details, gl) {
const gpuInfo = gl ? getGPUInfo(gl) : {};
window.ja?.("event", "webgl_error", {
error_type: errorType,
...details,
gpu_vendor: gpuInfo.vendor,
gpu_renderer: gpuInfo.renderer,
user_agent: navigator.userAgent,
screen_width: window.screen.width,
screen_height: window.screen.height,
device_pixel_ratio: window.devicePixelRatio,
timestamp: Date.now(),
});
}
function setupContextLossTracking(canvas, gl) {
canvas.addEventListener("webglcontextlost", (event) => {
trackWebGLError("context_lost", {
default_prevented: event.defaultPrevented,
recovering: false,
}, gl);
// Prevent default to allow recovery
event.preventDefault();
});
canvas.addEventListener("webglcontextrestored", () => {
trackWebGLError("context_restored", {
recovery_time_ms: Date.now() - window._contextLostAt,
}, gl);
// Your recovery logic here — reinitialize shaders, rebind textures, etc.
});
// Track when loss happens for recovery timing
canvas.addEventListener("webglcontextlost", () => {
window._contextLostAt = Date.now();
});
}
The GPU info is critical. I've debugged context loss issues that only happened on AMD GPUs on Windows, or only on Safari with Apple Silicon, or only on Firefox when the user had certain extensions installed. Without that metadata, you're guessing.
The WEBGL_debug_renderer_info extension exists specifically for this. Some browsers block it for fingerprinting reasons, but most allow it in first-party contexts. If you get "unknown," you're probably on a privacy-focused Firefox config. Still useful to track — tells you which users you're blind to.
Step 2: Shader compile error capture
Shader errors are the worst kind of WebGL bug. They're GPU and driver-specific. A shader that compiles fine on your NVIDIA card fails on someone's Intel integrated graphics. Different drivers implement different GLSL edge cases differently. And the error messages — oh, the error messages.
ERROR: 0:42: 'foo' : undeclared identifier
Line 42 of what? The compiler concatenated your includes.
Here's a wrapper that captures everything you need:
function compileShaderWithTracking(gl, type, source, shaderName = "unnamed") {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const error = gl.getShaderInfoLog(shader);
trackWebGLError("shader_compile_failed", {
shader_name: shaderName,
shader_type: type === gl.VERTEX_SHADER ? "vertex" : "fragment",
error_log: error,
// Don't send full source in production — hash it or truncate
source_hash: hashString(source),
source_preview: source.substring(0, 500),
}, gl);
gl.deleteShader(shader);
return null;
}
return shader;
}
function linkProgramWithTracking(gl, vertexShader, fragmentShader, programName = "unnamed") {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const error = gl.getProgramInfoLog(program);
trackWebGLError("program_link_failed", {
program_name: programName,
error_log: error,
}, gl);
gl.deleteProgram(program);
return null;
}
return program;
}
// Simple hash for shader source identification
function hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(16);
}
Name your shaders. When the error says "water_reflection vertex shader failed to compile on Intel HD Graphics 630," you know exactly where to look. When it says "unnamed vertex shader failed," you don't.
One thing I learned the hard way: don't send full shader source to your tracking service. Shaders can be thousands of lines. Hash them or send a preview. Enough to identify which shader, not enough to blow your event quota. (Ask me how I know. Actually, don't — I'd rather not relive hitting my monthly limit on day three because I was logging raw GLSL.)
Step 3: GL error monitoring per frame
Most GL errors don't throw. They just set an error flag that you have to check with gl.getError(). Miss the check, and you'll never know something's wrong until the visual output breaks.
function checkGLErrorsAfterFrame(gl, frameNumber, sceneName = "main") {
const error = gl.getError();
if (error !== gl.NO_ERROR) {
const errorNames = {
[gl.INVALID_ENUM]: "INVALID_ENUM",
[gl.INVALID_VALUE]: "INVALID_VALUE",
[gl.INVALID_OPERATION]: "INVALID_OPERATION",
[gl.INVALID_FRAMEBUFFER_OPERATION]: "INVALID_FRAMEBUFFER_OPERATION",
[gl.OUT_OF_MEMORY]: "OUT_OF_MEMORY",
[gl.CONTEXT_LOST_WEBGL]: "CONTEXT_LOST_WEBGL",
};
trackWebGLError("gl_error", {
error_code: error,
error_name: errorNames[error] || "UNKNOWN",
frame_number: frameNumber,
scene: sceneName,
}, gl);
// Clear the error state so we catch the next one
while (gl.getError() !== gl.NO_ERROR) {}
return true; // Error occurred
}
return false;
}
// Usage in your render loop
let frameCount = 0;
function render() {
// ... your render code ...
frameCount++;
// Check every frame in dev, every 60 frames in prod (adjust for your needs)
const checkInterval = process.env.NODE_ENV === "development" ? 1 : 60;
if (frameCount % checkInterval === 0) {
checkGLErrorsAfterFrame(gl, frameCount, currentScene.name);
}
requestAnimationFrame(render);
}
OUT_OF_MEMORY is the one you really want to catch. It usually means texture uploads failed, framebuffers didn't allocate, or you're just asking for more VRAM than the device has. Common on mobile. Common on older integrated GPUs. Common on that one user who still runs a 2015 MacBook Air.
The frame number and scene name give you context. "OUT_OF_MEMORY on frame 847 in boss_battle" tells you where to look. "OUT_OF_MEMORY" tells you nothing.
Step 4: Asset load failure tracking
Textures that fail to load. Models that 404. Audio that never arrives. These aren't GL errors, but they break the visual output just as hard.
function loadTextureWithTracking(gl, url, textureName) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
const startTime = Date.now();
image.onload = () => {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
// Check if upload succeeded
const error = gl.getError();
if (error !== gl.NO_ERROR) {
trackWebGLError("texture_upload_failed", {
texture_name: textureName,
texture_url: url,
image_width: image.width,
image_height: image.height,
error_code: error,
}, gl);
reject(new Error(`Texture upload failed: ${textureName}`));
return;
}
gl.generateMipmap(gl.TEXTURE_2D);
window.ja?.("event", "texture_loaded", {
texture_name: textureName,
load_time_ms: Date.now() - startTime,
width: image.width,
height: image.height,
});
resolve(texture);
} catch (e) {
trackWebGLError("texture_exception", {
texture_name: textureName,
error_message: e.message,
}, gl);
reject(e);
}
};
image.onerror = () => {
trackWebGLError("texture_load_failed", {
texture_name: textureName,
texture_url: url,
load_time_ms: Date.now() - startTime,
}, gl);
reject(new Error(`Failed to load texture: ${url}`));
};
image.src = url;
});
}
Texture upload failures are sneaky. The image loads fine — it's just that gl.texImage2D silently fails because the texture dimensions aren't power-of-two on a device that requires it, or the format isn't supported, or you've hit the texture unit limit. Check gl.getError() after every texture operation in your loader.
The load timing data is bonus. You'll start seeing patterns — "textures over 2MB take 3+ seconds on mobile connections" — that inform your asset optimization. That's analytics, not error tracking, but since JustAnalytics does both, you get it for free.
Step 5: Integration with Three.js (or your framework)
If you're using Three.js, Babylon.js, or PixiJS, you don't need to hook the raw GL context for everything. The frameworks expose higher-level error events.
Three.js example:
// Three.js integration
import * as THREE from "three";
const renderer = new THREE.WebGLRenderer({ canvas });
// Get the underlying context for GPU info
const gl = renderer.getContext();
// Context loss events still come from the canvas
setupContextLossTracking(renderer.domElement, gl);
// Three.js-specific: shader compilation happens internally
// Override the shader error handler
renderer.debug.checkShaderErrors = true;
// Hook into Three's console warnings for shader issues
const originalWarn = console.warn;
console.warn = (...args) => {
const message = args.join(" ");
if (message.includes("THREE.WebGLProgram") || message.includes("shader")) {
trackWebGLError("threejs_shader_warning", {
message: message.substring(0, 1000),
}, gl);
}
originalWarn.apply(console, args);
};
// Check for GL errors after render
const originalRender = renderer.render.bind(renderer);
renderer.render = (scene, camera) => {
originalRender(scene, camera);
checkGLErrorsAfterFrame(gl, renderer.info.render.frame, scene.name || "unnamed");
};
Not pretty, but it works. Honestly, I wish Three.js exposed shader compile errors through a clean API, but they don't — they go to console.warn and you have to intercept them. Same story with most frameworks. It's annoying. But you know what's more annoying? Not knowing why your game crashed.
If you're building something from scratch, use the raw GL wrappers from earlier. If you're on a framework, wrap their internals like this and accept that it's a little hacky. The alternative is not knowing when shaders fail.
Common errors and how to fix them
"WEBGL_debug_renderer_info not available"
Some browsers block this extension for privacy. Your GPU info will show "unknown." It's not a bug in your code — just a gap in your data. Track the browser/OS instead and correlate manually. Firefox with Resist Fingerprinting enabled is the usual culprit.
Context loss events fire but your app doesn't recover
You're not calling event.preventDefault() in your contextlost handler, or your recovery code isn't reinitializing everything. Context restoration gives you a fresh GL state — all textures, shaders, buffers, and framebuffers are gone. You need to recreate them. Most apps either don't handle this at all (crash) or handle it poorly (black screen until refresh).
Shader errors only on specific GPUs
Welcome to the joy of cross-device WebGL.
Look, I have opinions about this: GPU manufacturers should agree on GLSL behavior. They won't. Intel GPUs are strict about precision qualifiers. Older AMD drivers have bugs in certain GLSL functions. Mobile GPUs reject shaders that desktop GPUs accept. The fix is testing — actual testing on actual devices, or at minimum a service like BrowserStack that gives you real GPU diversity. Your shader works on NVIDIA and Apple Silicon? Great. It probably breaks on Intel HD 4000. For teams running multi-browser testing at scale, JustBrowser's antidetect profiles can help simulate different GPU fingerprints during QA.
OUT_OF_MEMORY but you're not loading that much
Textures are bigger than you think. A 4096x4096 RGBA texture is 64MB in VRAM. Load five of them and you've used 320MB — more than some integrated GPUs have. Check texture dimensions in your load tracking. The fix is usually mipmapping (lets the GPU use smaller versions), texture compression (DXT/PVRTC/ETC2), or just smaller textures.
gl.getError() always returns NO_ERROR even when things are broken
You might be checking too late. GL errors are "sticky" — once set, they persist until you call getError(). But some implementations clear errors at frame boundaries, or your framework is calling getError() internally and clearing the flag before you check. Call it immediately after the operation you're debugging.
(I once spent an entire afternoon on this. The error was happening. The check was in place. But something in my framework's render loop was calling getError() and eating the flag before my code ran. Moved my check to directly after the suspicious call, found the bug in thirty seconds.)
Next steps
You've got WebGL error tracking that catches the GPU-layer failures browsers don't surface. A few things worth adding:
Performance monitoring alongside errors. Frame timing, draw call counts, texture memory usage. When a user reports "the game is slow," you want data, not guesses. JustAnalytics bundles APM with error tracking — the Rails 7 analytics guide shows how to set up performance baselines alongside analytics.
Session replay for visual debugging. See what the user saw before the crash. JustAnalytics includes session replay that captures Canvas/WebGL output as well as DOM. It won't replay the actual WebGL state, but you'll see the frozen frame and what led to it. If you're evaluating options, see the JustAnalytics vs Plausible vs Fathom comparison for how observability platforms compare.
Uptime monitoring for your asset CDN. If textures fail to load because your CDN is down, you want to know before users report it. Same JustAnalytics account, same dashboard. Teams managing complex development setups often pair this with DevOS for coordinating across different environments and GPUs.
The pattern that emerges — user action, asset load, GL error, context loss — tells the story of what broke and why. Without tracking all the layers, you're guessing. With it, you're debugging.
WebGL debugging is still painful. Will probably always be painful, honestly — that's the cost of talking directly to the GPU from a browser sandbox. But it's a lot less painful when you know what happened instead of staring at a blank canvas and wondering why everything worked on your machine.
Frequently Asked Questions
Does WebGL context loss always mean my app crashed?
No. Context loss is recoverable — the browser fires a webglcontextlost event, and if you handle it properly (call event.preventDefault(), then restore state when webglcontextrestored fires), the user might not even notice. The crash happens when you don't handle it and your render loop throws exceptions trying to use a dead context. The goal of error tracking is to know when context loss happens and whether recovery succeeded.
Can I track shader compile errors in production?
Yes. Call gl.getShaderParameter(shader, gl.COMPILE_STATUS) after compileShader(), and if it returns false, grab the error with gl.getShaderInfoLog(shader). Same pattern for program linking with gl.getProgramParameter and gl.getProgramInfoLog. Send those to your error tracker with the shader source (or at least a hash of it) so you can reproduce. Most shader errors are device-specific — works on your MacBook, fails on someone's integrated Intel GPU.
How do I know if a user's GPU ran out of memory?
You usually don't get a direct signal. The symptoms are: texture uploads fail silently, framebuffer creation returns null, or the context is lost entirely. Some browsers expose gl.getError() returning OUT_OF_MEMORY (0x0505), but not all. The practical approach is to track texture/buffer allocations, monitor for context loss, and correlate with the user's GPU and browser version. Patterns emerge — certain devices hit memory walls at predictable asset loads.
Will this work with Three.js, Babylon.js, or PixiJS?
Yes, with minor adjustments. These frameworks wrap WebGL, so you hook into their error events instead of the raw GL context. Three.js exposes renderer.context for the underlying WebGLRenderingContext, and you can still add event listeners to the canvas. Babylon.js has engine.onContextLostObservable and engine.onContextRestoredObservable. PixiJS fires context-lost and context-restored events on the renderer. The error tracking patterns are the same — just different entry points.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.